t08-map-summary-walkthrough.mjs 16 KB

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