t07-coordinate-walkthrough.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /**
  2. * T07 真机走查:坐标准入与「无地图位置」在浏览器里确实成立(票 07)。
  3. *
  4. * 逻辑脚本(t07-coordinate.mjs)已把准入判据、六种原因文案、两个口径与清单渲染断言透了;
  5. * 这里只补**只有在真机才成立**的部分 ——
  6. * 1. 被拦下的对象确实**没进**「可上图」那一堆,而对象本身仍在界面上看得见(不是静默隐藏);
  7. * 2. 「无地图位置」清单带着**具体中文原因**真的渲染出来了;
  8. * 3. 后端全量口径与已加载口径在界面/宿主上是**两个数**,且与 mock 载荷对得上。
  9. *
  10. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
  11. * node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs
  12. *
  13. * 演示用的开关(宿主全局键注入,不改代码重新构建):
  14. * `window.__COCKPIT_MOCK_MAP__ = { pageSize }` —— mock 五条样例里就有三类坏坐标。
  15. */
  16. import { join } from 'node:path'
  17. import { tmpdir } from 'node:os'
  18. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  19. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  20. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  21. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  22. const results = []
  23. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过的假绿坑)。 */
  24. function check(id, pass, evidence) {
  25. results.push({ id, pass })
  26. const rest = evidence === undefined ? '' : String(evidence)
  27. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  28. }
  29. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  30. const puppeteer = (await import(PUPPETEER)).default
  31. const browser = await puppeteer.launch({
  32. executablePath: CHROME,
  33. headless: true,
  34. userDataDir: join(tmpdir(), 'cockpit-t07-profile'),
  35. defaultViewport: { width: 1680, height: 950 },
  36. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  37. })
  38. console.log(`Chrome: ${await browser.version()}`)
  39. /** 读坐标准入的界面状态(断言读 dataset 与 `data-*`,不读中文串;文案另行核对)。 */
  40. const readCoordinate = (page) =>
  41. page.evaluate(() => {
  42. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  43. const panel = document.querySelector('[data-map-blocked-panel]')
  44. const list = document.querySelector('[data-map-blocked-list]')
  45. const items = list ? Array.from(list.querySelectorAll('li[data-map-blocked-reason]')) : []
  46. return {
  47. engine: (host && host.dataset.mapEngine) || '',
  48. summary: (host && host.dataset.mapSummary) || '',
  49. loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
  50. total: Number((host && host.dataset.mapSummaryTotal) || 0),
  51. renderable: Number((host && host.dataset.mapRenderable) || 0),
  52. blocked: Number((host && host.dataset.mapBlocked) || 0),
  53. blockedBackend: (host && host.dataset.mapBlockedBackend) || '',
  54. crs: (host && host.dataset.mapCoordinateCrs) || '',
  55. panelCount: document.querySelectorAll('[data-map-blocked-panel]').length,
  56. panelExists: !!panel,
  57. countText: (document.querySelector('[data-map-blocked-count]') || {}).textContent || '',
  58. // 用**面板内**的选择器,不要用 `[data-map-blocked-backend]`:那个名字同时挂在
  59. // 地图宿主上(dataset),宿主在 DOM 里更靠前,querySelector 会先命中宿主,
  60. // 拿回整页文本 —— 断言就成了「拿整页文字做 includes」,是假绿(第一版踩过)。
  61. backendText: (panel?.querySelector('[data-map-coordinate-backend]') || {}).textContent || '',
  62. loadedText: (panel?.querySelector('[data-map-coordinate-loaded]') || {}).textContent || '',
  63. listVisible: !!list,
  64. reasons: items.map((li) => ({
  65. code: li.dataset.mapBlockedReason || '',
  66. text: (li.querySelector('.map-blocked__reason') || {}).textContent?.trim() || '',
  67. name: (li.querySelector('.map-blocked__name') || {}).textContent?.trim() || '',
  68. })),
  69. // 宿主内的 Cesium 画布(口径与票 27 的 A15 一致:`.cesium-widget canvas`)
  70. cesiumCanvas: host ? host.querySelectorAll('.cesium-widget canvas').length : -1,
  71. }
  72. })
  73. /** 点「加载下一页」并等已加载数真的变了(不知增量,所以等"变化"而不是等某个值)。 */
  74. async function clickAndWaitChange(page, previousLoaded, timeout = 8000) {
  75. const clicked = await page.evaluate(() => {
  76. const button = document.querySelector('[data-map-summary-action]')
  77. if (!button || button.disabled) return false
  78. button.click()
  79. return true
  80. })
  81. if (!clicked) return false
  82. await page
  83. .waitForFunction(
  84. (before) => {
  85. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  86. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  87. const phase = (host && host.dataset.mapSummary) || ''
  88. return loaded !== before || ['failed', 'expired', 'empty'].includes(phase)
  89. },
  90. { timeout },
  91. previousLoaded,
  92. )
  93. .catch(() => {})
  94. return true
  95. }
  96. /**
  97. * 把分页翻到底(完成/空/失败/失效)。
  98. *
  99. * 必须真的翻:外壳挂载后只自动取**第 1 批**(票 08 的渐进加载),
  100. * 而 mock 的五条样例按页大小 2 分成三批 —— 不翻页就只持有第 1 批,
  101. * 断言会以为"没有坏坐标对象",实际是还没取到(本脚本第一版正是这么写错的)。
  102. */
  103. async function advanceUntilSettled(page, maxSteps = 10) {
  104. const series = [await readCoordinate(page)]
  105. for (let step = 0; step < maxSteps; step += 1) {
  106. const current = series[series.length - 1]
  107. if (['complete', 'empty', 'failed', 'expired'].includes(current.summary)) break
  108. if (!(await clickAndWaitChange(page, current.loaded))) break
  109. series.push(await readCoordinate(page))
  110. }
  111. return series
  112. }
  113. async function openPage(inject) {
  114. const page = await browser.newPage()
  115. const errors = []
  116. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  117. page.on('console', (m) => {
  118. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  119. })
  120. await page.evaluateOnNewDocument(() => {
  121. // 引导层会盖住页面,走查不需要它(票 30 的开关)。
  122. window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
  123. })
  124. if (inject) await page.evaluateOnNewDocument(inject)
  125. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  126. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  127. await page
  128. .waitForFunction(
  129. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  130. { timeout: 30000 },
  131. )
  132. .catch(() => {})
  133. // 外壳挂载后会自动取第 1 批:等它落定再开始(票 08 同一条前置)。
  134. await page
  135. .waitForFunction(
  136. () => {
  137. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  138. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  139. const phase = (host && host.dataset.mapSummary) || ''
  140. return loaded > 0 || ['empty', 'failed', 'expired'].includes(phase)
  141. },
  142. { timeout: 15000 },
  143. )
  144. .catch(() => {})
  145. await sleep(300)
  146. return { page, errors }
  147. }
  148. /** 把「无地图位置」清单展开(默认收起,避免挡住地图)。 */
  149. async function expandList(page) {
  150. const clicked = await page.evaluate(() => {
  151. const button = document.querySelector('[data-map-blocked-toggle]')
  152. if (!button) return false
  153. button.click()
  154. return true
  155. })
  156. if (clicked) await sleep(250)
  157. return clicked
  158. }
  159. try {
  160. // ---------- A 被拦下的对象不上图,但仍在界面上看得见 ----------
  161. {
  162. const { page, errors } = await openPage(() => {
  163. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  164. })
  165. // 翻到底再断言:只取第 1 批时会看不到后两个坏坐标对象(见 advanceUntilSettled 的说明)。
  166. await advanceUntilSettled(page)
  167. const state = await readCoordinate(page)
  168. // mock 五条:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。
  169. check(
  170. 'A1-renderable-excludes-blocked',
  171. state.renderable === 2 && state.blocked === 3 && state.loaded === 5 && state.summary === 'complete',
  172. `可上图 ${state.renderable} · 不上图 ${state.blocked} · 已加载 ${state.loaded} · 终态 ${state.summary}`,
  173. )
  174. check(
  175. 'A2-objects-not-silently-dropped',
  176. state.panelExists && state.panelCount === 1 && Number(state.countText) === 3,
  177. `清单存在=${state.panelExists} · 计数=${state.countText}`,
  178. )
  179. // 后端全量口径(不随翻页跳动)与已加载口径是两个数,且都对得上 mock 载荷。
  180. check(
  181. 'A3-backend-caliber-is-whole-snapshot',
  182. state.blockedBackend === '3' && state.crs === 'WGS84',
  183. `后端全量不上图 ${state.blockedBackend}(非法 2 + 未校验 1)· crs=${state.crs}`,
  184. )
  185. check(
  186. 'A4-loaded-caliber-is-this-batch',
  187. state.loadedText.includes('已加载 5') && state.loadedText.includes('可上图 2') && state.loadedText.includes('无地图位置 3'),
  188. state.loadedText,
  189. )
  190. check(
  191. 'A5-two-calibers-worded-apart',
  192. state.backendText.includes('全量') && state.loadedText.includes('已加载') && state.backendText !== state.loadedText,
  193. `${state.backendText} | ${state.loadedText}`,
  194. )
  195. // 摘要措辞不得把被拦下的 3 个算成「已上图」。
  196. const label = await page.evaluate(() => (document.querySelector('[data-map-summary-label]') || {}).textContent || '')
  197. check(
  198. 'A6-summary-label-not-inflated',
  199. label.includes('已上图 2 个') && label.includes('无地图位置 3 个'),
  200. label,
  201. )
  202. check('A7-engine-still-alive', state.engine === 'ready' && state.cesiumCanvas === 1, `引擎 ${state.engine} · 画布 ${state.cesiumCanvas}`)
  203. check('A8-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  204. await page.close()
  205. }
  206. // ---------- B 「无地图位置」清单带具体中文原因 ----------
  207. {
  208. const { page, errors } = await openPage(() => {
  209. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  210. })
  211. await advanceUntilSettled(page)
  212. const before = await readCoordinate(page)
  213. check('B1-list-collapsed-by-default', before.listVisible === false, `默认展开=${before.listVisible}`)
  214. const expanded = await expandList(page)
  215. const after = await readCoordinate(page)
  216. check('B2-list-expands', expanded && after.listVisible === true, `展开成功=${expanded}`)
  217. // 三个被拦下的对象各带**具体原因**(不是笼统的「无地图位置」)。
  218. const texts = after.reasons.map((item) => item.text)
  219. check(
  220. 'B3-reasons-are-specific',
  221. after.reasons.length === 3 &&
  222. texts.includes('坐标未经校验') &&
  223. texts.includes('经度超出合法范围') &&
  224. texts.includes('零坐标(经纬度均为 0)'),
  225. JSON.stringify(after.reasons.map((item) => `${item.name || '-'}:${item.text}`)),
  226. )
  227. check(
  228. 'B4-reason-codes-on-dataset-only',
  229. after.reasons.map((item) => item.code).sort().join(',') === 'LONGITUDE_OUT_OF_RANGE,UNVERIFIED_COORDINATE,ZERO_COORDINATE',
  230. JSON.stringify(after.reasons.map((item) => item.code)),
  231. )
  232. // 界面文案里不得出现后端原因码原文(票 05 立的纪律,票 07 沿用)。
  233. const leaked = after.reasons.filter((item) => /^[A-Z_]{6,}$/.test(item.text))
  234. check('B5-no-raw-reason-code-on-screen', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文')
  235. check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  236. await page.close()
  237. }
  238. // ---------- C 空样例与字段缺失样例的坐标准入 ----------
  239. {
  240. // 空快照:没有对象 → 无地图位置 0,不报失败。
  241. // 注意后端汇总是**空的**而不是 0:票 05 的 `sectionFromResult` 对 `EMPTY` 分区不带载荷,
  242. // 那份全零汇总在适配层就被放下了(数据面确实拿到过,但领域层按契约丢掉)。
  243. // 所以这里断言 dataset 为空串,且界面文案如实说「未取到」而不是编一个 0。
  244. const { page } = await openPage(() => {
  245. window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
  246. window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
  247. })
  248. const empty = await readCoordinate(page)
  249. check(
  250. 'C1-empty-scenario-zero-blocked',
  251. empty.summary === 'empty' && empty.blocked === 0 && empty.renderable === 0 && empty.blockedBackend === '' && empty.backendText.includes('未取到'),
  252. JSON.stringify({ summary: empty.summary, blocked: empty.blocked, backend: empty.blockedBackend, text: empty.backendText }),
  253. )
  254. await page.close()
  255. }
  256. {
  257. // 字段缺失样例:点位没有 coordinateValidation → 「坐标未校验」,仍被列出来(不静默隐藏)。
  258. // 这个样例是**单页且 complete:false**(票 05 的原样),所以只取第 1 批、不继续翻页。
  259. const { page } = await openPage(() => {
  260. window.__COCKPIT_MOCK_SCENARIO__ = 'degraded'
  261. window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
  262. })
  263. await expandList(page)
  264. const degraded = await readCoordinate(page)
  265. check(
  266. 'C2-degraded-is-unverified-not-hidden',
  267. degraded.loaded === 1 && degraded.renderable === 0 && degraded.blocked === 1 && degraded.reasons[0]?.text === '坐标未校验',
  268. JSON.stringify({ loaded: degraded.loaded, reasons: degraded.reasons.map((item) => item.text) }),
  269. )
  270. await page.close()
  271. }
  272. // ---------- D 清单不把地图或页面撑出问题 ----------
  273. {
  274. const { page, errors } = await openPage(() => {
  275. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  276. })
  277. await advanceUntilSettled(page)
  278. await expandList(page)
  279. const layout = await page.evaluate(() => {
  280. const shell = document.querySelector('.cockpit-shell')
  281. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  282. const panel = document.querySelector('[data-map-blocked-panel]')
  283. const panelRect = panel ? panel.getBoundingClientRect() : null
  284. const hostRect = host ? host.getBoundingClientRect() : null
  285. return {
  286. panelInsideHost:
  287. !!panelRect &&
  288. !!hostRect &&
  289. panelRect.left >= hostRect.left - 1 &&
  290. panelRect.right <= hostRect.right + 1 &&
  291. panelRect.bottom <= hostRect.bottom + 1,
  292. docOverflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth,
  293. hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  294. shellVisible: !!shell && shell.getBoundingClientRect().height > 100,
  295. }
  296. })
  297. check('D1-panel-contained-in-map', layout.panelInsideHost, JSON.stringify(layout))
  298. check('D2-no-horizontal-overflow', layout.docOverflowX <= 0, `横向溢出 ${layout.docOverflowX}px`)
  299. check('D3-single-map-host', layout.hosts === 1 && layout.shellVisible, JSON.stringify({ hosts: layout.hosts }))
  300. check('D4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  301. await page.close()
  302. }
  303. } finally {
  304. await browser.close()
  305. }
  306. const failed = results.filter((item) => !item.pass)
  307. console.log('')
  308. console.log(`T07 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  309. if (failed.length) {
  310. console.log('未通过:' + failed.map((item) => item.id).join(', '))
  311. process.exit(1)
  312. }