t08-map-summary-walkthrough.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. /**
  2. * T08 真机走查:地图摘要分页与快照在浏览器里确实成立(票 08)。
  3. *
  4. * 逻辑脚本(t08-map-summary.mjs)已经把合并、装载器状态机与分区映射测透了;这里只补
  5. * **只有在真机才成立**的部分 —— 票 08 验收第 1 条原句:
  6. * 「地图先显示框架,再按分页批次增加摘要点位」。
  7. * 纯逻辑断言证明不了「框架先于点位出现」:那要求页面真的渲染出来、地图宿主与 Cesium 引擎先就绪。
  8. *
  9. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
  10. * node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs
  11. *
  12. * 演示用的开关(都由宿主全局键注入,不改代码重新构建):
  13. * `window.__COCKPIT_MOCK_SCENARIO__` / `window.__COCKPIT_MOCK_MAP__`
  14. * 人工演示同一件事:控制台里 `__cockpitMap.mapSummary.advance()` 一页页推进。
  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. /**
  24. * `check(id, pass, evidence)` —— 注意参数顺序:第二参是**断言结果**,不是描述。
  25. * (踩过一次:签名多了一个 `desc`,结果每个断言都把证据字符串当成了"通过",走查假绿 11/11。)
  26. */
  27. function check(id, pass, evidence) {
  28. results.push({ id, pass })
  29. const rest = evidence === undefined ? '' : String(evidence)
  30. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  31. }
  32. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  33. const puppeteer = (await import(PUPPETEER)).default
  34. const browser = await puppeteer.launch({
  35. executablePath: CHROME,
  36. headless: true,
  37. userDataDir: join(tmpdir(), 'cockpit-t08-profile'),
  38. defaultViewport: { width: 1680, height: 950 },
  39. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  40. })
  41. console.log(`Chrome: ${await browser.version()}`)
  42. /** 读地图宿主上的摘要状态(断言不读中文文案,读 dataset)。 */
  43. const readSummary = (page) =>
  44. page.evaluate(() => {
  45. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  46. const action = document.querySelector('[data-map-summary-action]')
  47. return {
  48. phase: (host && host.dataset.mapSummary) || '',
  49. loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
  50. total: Number((host && host.dataset.mapSummaryTotal) || 0),
  51. snapshot: (host && host.dataset.mapSummarySnapshot) || '',
  52. page: Number((host && host.dataset.mapSummaryPage) || 0),
  53. engine: (host && host.dataset.mapEngine) || '',
  54. actionLabel: action ? action.textContent.trim() : '',
  55. actionDisabled: action ? action.disabled : null,
  56. label: (document.querySelector('[data-map-summary-label]') || {}).textContent || '',
  57. board: Array.from(document.querySelectorAll('[data-section-board] [data-section-state]')).map((t) => ({
  58. name: t.dataset.sectionName,
  59. state: t.dataset.sectionState,
  60. retained: t.dataset.sectionRetained,
  61. })),
  62. hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  63. }
  64. })
  65. async function openPage(inject) {
  66. const page = await browser.newPage()
  67. const errors = []
  68. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  69. page.on('console', (m) => {
  70. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  71. })
  72. await page.evaluateOnNewDocument(() => {
  73. // 引导层会盖住页面,走查不需要它(票 30 的开关)。
  74. window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
  75. })
  76. if (inject) await page.evaluateOnNewDocument(inject)
  77. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  78. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  79. // 引擎要拉静态资源/地形,比取数慢:先等它落到终态,否则「框架先就绪」会被误判。
  80. await page
  81. .waitForFunction(
  82. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  83. { timeout: 30000 },
  84. )
  85. .catch(() => {})
  86. // 外壳挂载后会自动取第 1 批(票 08 的"再按分页批次增加"):等它落定再断言,
  87. // 否则会把"正在取第 1 批"误判成"框架就绪但一个点都没有"。
  88. await waitForFirstBatch(page)
  89. await sleep(300)
  90. return { page, errors }
  91. }
  92. /** 点「加载下一页」并等**已上图对象数真的变了**(不知增量,所以等"变化"而不是等某个值)。 */
  93. async function clickAndWaitChange(page, previousLoaded, timeout = 8000) {
  94. const clicked = await page.evaluate(() => {
  95. const button = document.querySelector('[data-map-summary-action]')
  96. if (!button || button.disabled) return false
  97. button.click()
  98. return true
  99. })
  100. if (!clicked) return false
  101. await page
  102. .waitForFunction(
  103. (before) => {
  104. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  105. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  106. const phase = (host && host.dataset.mapSummary) || ''
  107. return loaded !== before || phase === 'failed' || phase === 'expired'
  108. },
  109. { timeout },
  110. previousLoaded,
  111. )
  112. .catch(() => {})
  113. return true
  114. }
  115. /** 等外壳自动取完第 1 批(票 08:地图挂载后自己先取一批,不等用户点)。 */
  116. async function waitForFirstBatch(page, timeout = 15000) {
  117. await page
  118. .waitForFunction(
  119. () => {
  120. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  121. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  122. const phase = (host && host.dataset.mapSummary) || ''
  123. return loaded > 0 || phase === 'empty' || phase === 'failed' || phase === 'expired'
  124. },
  125. { timeout },
  126. )
  127. .catch(() => {})
  128. }
  129. /** 一批一批点,直到不再有下一页(完成/空/失败/失效)或点不动了。返回每一步的状态。 */
  130. async function advanceUntilSettled(page, maxSteps = 10) {
  131. const series = [await readSummary(page)]
  132. for (let step = 0; step < maxSteps; step += 1) {
  133. const current = series[series.length - 1]
  134. if (['complete', 'empty', 'failed', 'expired'].includes(current.phase)) break
  135. if (!(await clickAndWaitChange(page, current.loaded))) break
  136. series.push(await readSummary(page))
  137. }
  138. return series
  139. }
  140. try {
  141. // ---------- A 「地图先显示框架,再按分页批次增加摘要点位」----------
  142. {
  143. const { page, errors } = await openPage(() => {
  144. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  145. })
  146. const first = await readSummary(page)
  147. // 外壳在挂载后自动取第 1 批(票 08 原句的"再按分页批次增加"):首屏就应看到框架 + 第 1 批,
  148. // 而不是等全部取完。`loaded < total` 正是"渐进"的判据。
  149. check(
  150. 'A1-frame-before-points',
  151. ['ready', 'disabled'].includes(first.engine) && first.phase !== 'failed' && first.loaded > 0 && first.loaded < first.total,
  152. JSON.stringify({ engine: first.engine, phase: first.phase, loaded: first.loaded, total: first.total }),
  153. )
  154. check('A1-host-alive', first.hosts === 1, `地图宿主 ${first.hosts} 个`)
  155. const series = await advanceUntilSettled(page)
  156. const loadedSeries = series.map((item) => item.loaded)
  157. const snapshots = new Set(series.map((item) => item.snapshot).filter(Boolean))
  158. const last = series[series.length - 1]
  159. check(
  160. 'A2-progressive-batches',
  161. loadedSeries.join(',') === '2,4,5' && last.phase === 'complete' && last.total === 5,
  162. `已上图序列 ${loadedSeries.join(' → ')} · 总数 ${last.total} · 终态 ${last.phase}`,
  163. )
  164. check(
  165. 'A3-one-snapshot-per-load',
  166. snapshots.size === 1 && [...snapshots][0] !== '',
  167. `快照 ${[...snapshots].join(', ')}`,
  168. )
  169. check(
  170. 'A4-batch-count-consistent',
  171. series.length === 3 && loadedSeries[0] === 2 && loadedSeries[1] === 4,
  172. `批次数 ${series.length} · 序列 ${loadedSeries.join(' → ')}`,
  173. )
  174. // 取完之后按钮从「加载下一页」变成「刷新快照」:完成态是终态,再点就是换新快照。
  175. check(
  176. 'A5-complete-switches-action',
  177. last.actionLabel.includes('刷新') && first.actionLabel.includes('加载'),
  178. `按钮:${first.actionLabel} → ${last.actionLabel}`,
  179. )
  180. check('A6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  181. await page.close()
  182. }
  183. // ---------- B 刷新切换到新快照,旧快照的点不留在图上 ----------
  184. {
  185. const { page } = await openPage(() => {
  186. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  187. })
  188. const series = await advanceUntilSettled(page)
  189. const completed = series[series.length - 1]
  190. // 完成态下点这个按钮 = `mapStore.refresh()`:丢掉当前快照,从第 1 页重来。
  191. await clickAndWaitChange(page, completed.loaded)
  192. await sleep(400)
  193. const refreshed = await readSummary(page)
  194. check(
  195. 'B1-refresh-back-to-page-1',
  196. refreshed.page === 1 && refreshed.loaded === 2 && refreshed.phase !== 'expired' && refreshed.phase !== 'failed',
  197. JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded, phase: refreshed.phase, snapshot: refreshed.snapshot }),
  198. )
  199. check(
  200. 'B2-old-snapshot-not-accumulated',
  201. refreshed.loaded < completed.loaded,
  202. `完成时 ${completed.loaded} → 刷新后 ${refreshed.loaded}(旧快照的点没有留下来)`,
  203. )
  204. await page.close()
  205. }
  206. // ---------- C 失败:分区标失败,地图框架与已上图点位保留 ----------
  207. {
  208. const { page } = await openPage(() => {
  209. window.__COCKPIT_MOCK_SCENARIO__ = 'failed'
  210. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  211. })
  212. const failed = await readSummary(page)
  213. const mapSection = failed.board.find((item) => item.name === 'mapSnapshot')
  214. check(
  215. 'C1-failed-shows-on-board',
  216. failed.phase === 'failed' && !!mapSection && mapSection.state === 'failed' && failed.engine !== '',
  217. JSON.stringify({ phase: failed.phase, section: mapSection }),
  218. )
  219. check(
  220. 'C2-failed-keeps-frame',
  221. failed.hosts === 1 && failed.label.includes('失败'),
  222. JSON.stringify({ hosts: failed.hosts, label: failed.label }),
  223. )
  224. await page.close()
  225. }
  226. // ---------- D 快照过期:取完第 1 页后后端换快照,界面说「已失效」并保留已加载的点 ----------
  227. {
  228. const { page } = await openPage(() => {
  229. window.__COCKPIT_MOCK_SCENARIO__ = 'stale'
  230. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2, expireAfterPage: 1 }
  231. })
  232. // 首屏自动取到的就是第 1 页(旧快照);再点一次,后端已经换快照 → 失效。
  233. const before = await readSummary(page)
  234. await clickAndWaitChange(page, before.loaded)
  235. await sleep(500)
  236. const expired = await readSummary(page)
  237. check(
  238. 'D1-expired-after-first-page',
  239. expired.phase === 'expired' && expired.loaded === before.loaded && expired.label.includes('失效'),
  240. JSON.stringify({ before: `${before.phase}/${before.loaded}`, phase: expired.phase, loaded: expired.loaded, label: expired.label }),
  241. )
  242. check(
  243. 'D2-expired-not-merged',
  244. expired.snapshot !== '' && expired.total === before.total,
  245. JSON.stringify({ snapshot: expired.snapshot, total: expired.total }),
  246. )
  247. await page.close()
  248. }
  249. // ---------- E 空快照:不是失败,也不是「加载完成」的假象 ----------
  250. {
  251. const { page } = await openPage(() => {
  252. window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
  253. })
  254. const empty = await readSummary(page)
  255. const mapSection = empty.board.find((item) => item.name === 'mapSnapshot')
  256. check(
  257. 'E1-empty-is-neutral',
  258. empty.phase === 'empty' && empty.loaded === 0 && (empty.label.includes('没有可上图对象') || empty.label.includes('无数据')),
  259. JSON.stringify({ phase: empty.phase, label: empty.label, section: mapSection && mapSection.state }),
  260. )
  261. await page.close()
  262. }
  263. // ---------- F 布局:新增的「地图摘要」控件不得把筛选条撑破(票 08 改到了顶栏)----------
  264. {
  265. const { page } = await openPage(() => {
  266. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  267. })
  268. const probe = () =>
  269. page.evaluate(() => {
  270. const scope = document.querySelector('.cockpit-shell__scope')
  271. const summary = document.querySelector('.cockpit-shell__map-summary')
  272. const doc = document.documentElement
  273. return {
  274. scopeOverflow: scope ? scope.scrollWidth - scope.clientWidth : -1,
  275. summaryVisible: !!summary && summary.getBoundingClientRect().width > 0,
  276. summaryInside: summary && scope ? summary.getBoundingClientRect().right <= scope.getBoundingClientRect().right + 1 : false,
  277. docOverflow: doc.scrollWidth - doc.clientWidth,
  278. }
  279. })
  280. const at1680 = await probe()
  281. await page.setViewport({ width: 1280, height: 720 })
  282. await sleep(500)
  283. const at1280 = await probe()
  284. check(
  285. 'F1-filter-bar-not-overflowing',
  286. at1680.scopeOverflow <= 0 && at1680.docOverflow <= 0 && at1280.scopeOverflow <= 0 && at1280.docOverflow <= 0,
  287. JSON.stringify({ at1680, at1280 }),
  288. )
  289. check(
  290. 'F2-summary-widget-visible-and-inside',
  291. at1680.summaryVisible && at1680.summaryInside && at1280.summaryVisible,
  292. JSON.stringify({ at1680: { visible: at1680.summaryVisible, inside: at1680.summaryInside }, at1280: { visible: at1280.summaryVisible } }),
  293. )
  294. await page.close()
  295. }
  296. } finally {
  297. await browser.close()
  298. }
  299. const failed = results.filter((r) => !r.pass)
  300. console.log('')
  301. console.log(`T08 走查:${results.length - failed.length}/${results.length} 项通过。`)
  302. if (failed.length) {
  303. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  304. process.exit(1)
  305. }
  306. console.log('T08 走查全部通过。')