t17-infrastructure-panels-walkthrough.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. /**
  2. * T17 真机走查:基础设施模式四块面板按真实端点画出来、四块各自成败、点可定位对象真的联动地图(票 17)。
  3. *
  4. * 逻辑脚本(t17-infrastructure-panels.mjs,49/49)已经把契约适配、视图计划、四块独立成败、
  5. * 行业映射与定位判定测透了;这里只补**只有在真机才成立**的部分:
  6. * 1. 进基础设施模式后,**界面真的**把四块面板画出来(DOM 实证,不是读 store);
  7. * 2. 一块端点坏掉时,坏的那块显示失败措辞,**好的三块仍然画出真实内容**
  8. * —— 这是本票验收里唯一只有真机才能验的形态(与票 11 同款);
  9. * 3. 点面板里的可定位对象 → 相机真的动了 + 打开了同一张点位详情卡(验收第 6 条);
  10. * 4. 面板展开/收起仍真实改变高度(票 01 的语义不回退);
  11. * 5. 四块面板的数据适配在 DOM 上看得见(不是只有 store 里有值)。
  12. *
  13. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行):
  14. * node .scratch/cockpit-shell-panel-layout/verify/t17-infrastructure-panels-walkthrough.mjs
  15. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  16. *
  17. * **场景注入的关键**(与票 11 同一套手法):用 `__COCKPIT_MOCK_ONLY__` 把异常样例
  18. * **只**施加在基础设施的三个端点上,地图与其它分区保持 success —— 否则全局 empty 会把地图清空,
  19. * 「点可定位对象」就退化成"查不到点位"的假失败。
  20. *
  21. * 演示开关(宿主全局键注入,不改代码重新构建):
  22. * `__COCKPIT_MOCK_ONLY__='/infrastructure/'` + `__COCKPIT_MOCK_SCENARIO__='failed'`
  23. * —— 基础设施四块里三块失败、地图与其它分区照常
  24. * `...SCENARIO__='infrastructure-partial'` —— 应急保障的「防护目标」段没取到,其余段照常画
  25. * `...SCENARIO__='empty'` —— 四块都是"该面板当前没有数据"
  26. */
  27. import { join } from 'node:path'
  28. import { tmpdir } from 'node:os'
  29. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  30. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  31. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  32. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  33. /** 异常样例只施加在基础设施族端点上(地图与点位摘要保持 success,否则没有可定位的点位)。 */
  34. const INFRA_ONLY = '/infrastructure/'
  35. const results = []
  36. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  37. function check(id, pass, evidence) {
  38. results.push({ id, pass })
  39. const rest = evidence === undefined ? '' : String(evidence)
  40. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  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-t17-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. /**
  53. * 一次读全:四块面板的 DOM(状态标签 / 块类型 / KPI / 分布 / 清单 / 可定位按钮 / 缺口说明)
  54. * + 地图侧口径(标记数、相机、详情卡)。
  55. *
  56. * 面板内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
  57. */
  58. const readPanels = (page) =>
  59. page.evaluate(() => {
  60. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  61. const panels = Array.from(document.querySelectorAll('[data-panel-blocks]'))
  62. const card = document.querySelector('[data-point-detail]')
  63. const dev = window.__cockpitMap
  64. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  65. const camera = dev && dev.getCameraState ? dev.getCameraState() : null
  66. const panelView = (el) => ({
  67. id: el.dataset.panelId || '',
  68. state: (el.querySelector('[data-section-state]') || {}).dataset?.sectionState || '',
  69. stateText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
  70. blocks: Array.from(el.querySelectorAll('[data-panel-block]')).map((b) => b.dataset.panelBlock || ''),
  71. kpis: Array.from(el.querySelectorAll('[data-kpi]')).map((k) => ({
  72. key: k.dataset.kpi || '',
  73. label: ((k.querySelector('.panel-kpi__label') || {}).textContent || '').trim(),
  74. value: ((k.querySelector('.panel-kpi__value') || {}).textContent || '').trim(),
  75. })),
  76. bars: Array.from(el.querySelectorAll('[data-bar]')).map((b) => ({
  77. key: b.dataset.bar || '',
  78. label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
  79. count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
  80. })),
  81. items: Array.from(el.querySelectorAll('[data-item]')).map((i) => ({
  82. key: i.dataset.item || '',
  83. title: ((i.querySelector('.panel-item__title') || {}).textContent || '').trim(),
  84. meta: ((i.querySelector('.panel-item__meta') || {}).textContent || '').trim(),
  85. })),
  86. locatePoints: Array.from(el.querySelectorAll('[data-locate-point]')).map((b) => b.dataset.locatePoint || ''),
  87. gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(),
  88. locateNote: ((el.querySelector('[data-locate-note]') || {}).textContent || '').trim(),
  89. missingText: ((el.querySelector('[data-panel-missing]') || {}).textContent || '').trim(),
  90. placeholder: ((el.querySelector('[data-panel-placeholder]') || {}).textContent || '').trim(),
  91. itemState: ((el.querySelector('[data-item-state]') || {}).textContent || '').trim(),
  92. // 每段(list 块)自己的标题与状态说明 —— 按段断言,不靠"某一个选择器恰好命中"。
  93. listSections: Array.from(el.querySelectorAll('[data-panel-block="list"]')).map((sec) => ({
  94. label: ((sec.querySelector('.panel-block__title') || {}).textContent || '').trim(),
  95. stateText: ((sec.querySelector('[data-item-state]') || {}).textContent || '').trim(),
  96. emptyText: ((sec.querySelector('[data-item-empty]') || {}).textContent || '').trim(),
  97. rows: sec.querySelectorAll('[data-item]').length,
  98. })),
  99. barEmpty: ((el.querySelector('[data-bar-empty]') || {}).textContent || '').trim(),
  100. // 面板在页面上的可见高度(展开/收起与"真的画出来了"都靠它)
  101. height: Math.round(el.getBoundingClientRect().height),
  102. })
  103. return {
  104. mode: (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent?.trim() || '',
  105. panelCount: panels.length,
  106. panels: panels.map(panelView),
  107. // 本模式下仍是占位的块数(票 20 起 16 块已全部接上真面板,故这里是 0)
  108. placeholders: document.querySelectorAll('.cockpit-placeholder').length,
  109. locateFeedback: ((document.querySelector('[data-locate-result]') || {}).textContent || '').trim(),
  110. locateResult: (document.querySelector('[data-locate-result]') || {}).dataset?.locateResult || '',
  111. // 地图侧
  112. engine: (host && host.dataset.mapEngine) || '',
  113. markers: markers ? markers.onMap : -1,
  114. camera: camera ? camera.pivot : null,
  115. hasCard: !!card,
  116. cardPointCode: card ? card.dataset.pointCode : '',
  117. detailLevel: card ? card.dataset.detailLevel : '',
  118. visibleText: document.querySelector('.cockpit-shell__column') ? document.querySelector('.cockpit-shell__column').innerText : '',
  119. }
  120. })
  121. /** 打开页面 → 切到基础设施模式 → 走真实的进入引导。 */
  122. async function openPage(scenario, onlyPath = INFRA_ONLY) {
  123. const page = await browser.newPage()
  124. const errors = []
  125. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  126. page.on('console', (m) => {
  127. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  128. })
  129. await page.evaluateOnNewDocument(
  130. ({ name, only }) => {
  131. window.__COCKPIT_MOCK_SCENARIO__ = name
  132. // 两个键语义不同(见 `mocks/index.js`):`MOCK_ONLY` 是**精确**路径,
  133. // `MOCK_PREFIX` 才按前缀选中一族端点。写错键的后果是"异常样例根本没生效",
  134. // 而走查仍会绿 —— 那是假绿,所以这里显式按结尾斜杠分派。
  135. if (only.endsWith('/')) window.__COCKPIT_MOCK_PREFIX__ = only
  136. else window.__COCKPIT_MOCK_ONLY__ = only
  137. // 页大小给大一点:一次翻完,免得"点位还没加载到"把定位测成假失败。
  138. window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
  139. },
  140. { name: scenario, only: onlyPath },
  141. )
  142. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  143. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  144. await page
  145. .waitForFunction(
  146. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  147. { timeout: 40000 },
  148. )
  149. .catch(() => {})
  150. if (await page.$('[data-cockpit-intro]')) {
  151. await page.click('[data-cockpit-intro]')
  152. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  153. }
  154. await sleep(1200)
  155. // 切到基础设施模式(一级模式切换不重建地图,票 01)。
  156. await page.evaluate(() => {
  157. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '基础设施')
  158. if (button) button.click()
  159. })
  160. // 等四块面板都落地(四块各自成败,所以判据是"都拿到了分区状态",不是"都成功")。
  161. await page
  162. .waitForFunction(
  163. () => document.querySelectorAll('[data-panel-blocks]').length === 4 &&
  164. Array.from(document.querySelectorAll('[data-panel-blocks] [data-section-state]')).length >= 4,
  165. { timeout: 20000 },
  166. )
  167. .catch(() => {})
  168. await sleep(800)
  169. return { page, errors }
  170. }
  171. /** 翻完地图摘要分页(可定位对象的坐标来自已加载的地图对象,所以要等它到位)。 */
  172. async function advanceUntilSettled(page, maxSteps = 8) {
  173. for (let step = 0; step < maxSteps; step += 1) {
  174. const phase = await page.evaluate(() => (document.querySelector('.cockpit-shell__map .map-canvas') || {}).dataset?.mapSummary || '')
  175. if (['complete', 'empty', 'failed', 'expired'].includes(phase)) break
  176. const clicked = await page.evaluate(() => {
  177. const button = document.querySelector('[data-map-summary-action]')
  178. if (!button || button.disabled) return false
  179. button.click()
  180. return true
  181. })
  182. if (!clicked) break
  183. await sleep(500)
  184. }
  185. return page.evaluate(() => Number((document.querySelector('.cockpit-shell__map .map-canvas') || {}).dataset?.mapSummaryLoaded || 0))
  186. }
  187. /** 等相机真的落到目标附近(阻尼飞行途中读数不准)。 */
  188. async function waitForCameraNear(page, target, timeout = 20000) {
  189. await page
  190. .waitForFunction(
  191. (expected) => {
  192. const camera = window.__cockpitMap && window.__cockpitMap.getCameraState ? window.__cockpitMap.getCameraState() : null
  193. if (!camera || !camera.pivot) return false
  194. const dLon = Math.abs(camera.pivot.lon - expected.lon)
  195. const dLat = Math.abs(camera.pivot.lat - expected.lat)
  196. return dLon < 0.01 && dLat < 0.01
  197. },
  198. { timeout },
  199. target,
  200. )
  201. .catch(() => {})
  202. }
  203. const panelOf = (state, id) => state.panels.find((p) => p.id === id) || null
  204. // ================================================================ A 正常场景:四块都画出来
  205. {
  206. const { page, errors } = await openPage('success')
  207. const state = await readPanels(page)
  208. check('A1-infrastructure-mode-active', state.mode === '基础设施', `当前一级模式:${state.mode}`)
  209. check('A2-four-panels-rendered', state.panelCount === 4, `画出的面板数:${state.panelCount}`)
  210. check(
  211. 'A3-all-four-panels-succeeded',
  212. state.panels.every((p) => p.state === 'success'),
  213. state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
  214. )
  215. const archive = panelOf(state, 'archive')
  216. check(
  217. 'A4-archive-shows-scale-industry-region-age',
  218. archive.kpis.some((k) => k.label === '设施总数' && k.value.includes('5')) &&
  219. archive.bars.some((b) => b.label === '燃气' || b.label === 'GAS') &&
  220. archive.bars.some((b) => b.count === '3') &&
  221. archive.blocks.includes('bars'),
  222. `档案 KPI ${archive.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};分布项 ${archive.bars.length} 条`,
  223. )
  224. // 规模必须是中文(真机走查逮到过一次英文枚举直接上屏:`规模=medium`)。
  225. // 这条按**取值**断言,不是"只要不是空就行"。
  226. const scaleKpi = archive.kpis.find((k) => k.label === '规模')
  227. check(
  228. 'A4b-scale-shown-in-chinese',
  229. !!scaleKpi && ['大型', '中型', '小型'].includes(scaleKpi.value),
  230. `规模读数:${scaleKpi ? scaleKpi.value : '(缺 KPI)'}(应为大型/中型/小型之一,不得直接印英文枚举)`,
  231. )
  232. const operation = panelOf(state, 'operation')
  233. check(
  234. 'A5-operation-shows-three-capabilities',
  235. operation.kpis.map((k) => k.label).join(',') === '供应,输送,承载' &&
  236. operation.kpis.every((k) => k.value && k.value !== '—'),
  237. `三类能力:${operation.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')}`,
  238. )
  239. const management = panelOf(state, 'management')
  240. check(
  241. 'A6-management-shows-units-and-region-gap',
  242. management.items.length >= 2 && !!management.gap && management.gap.includes('责任区域'),
  243. `${management.items.length} 家管理单位;责任区域如实标为契约缺口`,
  244. )
  245. const emergency = panelOf(state, 'emergency')
  246. check(
  247. 'A7-emergency-shows-three-sections',
  248. emergency.kpis.map((k) => k.label).join(',') === '危险源,防护目标,应急资源' && emergency.items.length >= 3,
  249. `三段 KPI ${emergency.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};条目 ${emergency.items.length} 条`,
  250. )
  251. // 「其余面板仍是占位」要分两步验:基础设施模式**自己就是**那 4 块真实面板,
  252. // 所以这里占位应当是 0;切到别的模式才该看到占位。
  253. check(
  254. 'A8-infrastructure-mode-has-no-placeholders',
  255. state.placeholders === 0,
  256. `基础设施模式下占位面板:${state.placeholders} 块(该模式 4 块全是真实面板)`,
  257. )
  258. // 切到总体概览:**本票不锁死它的占位块数**。
  259. //
  260. // 票 20 修正(2026-09-29):这里原先断言"恰好 4 块占位"(票 18/19/20 的域),
  261. // 而票 20 把总体概览四块接上真面板之后它必然假红 —— **判据钉在了另一张票的进度上**。
  262. // 票 18 在**完全相同**的处境下已经栽过一次(其 A8b 原判据"切到预警处置 → 恰好 4 块占位"
  263. // 被票 19 接真面板后打红),并在自己那侧改成了"只断言本票模式、其余只汇报"。
  264. // 本票沿用同一办法:**只验本票自己的模式是 0**,别的模式的数量仅作汇报。
  265. await page.evaluate(() => {
  266. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '总体概览')
  267. if (button) button.click()
  268. })
  269. await sleep(600)
  270. const overviewPlaceholders = await page.evaluate(() => document.querySelectorAll('.cockpit-placeholder').length)
  271. const overviewReal = await page.evaluate(() => document.querySelectorAll('[data-panel-blocks]').length)
  272. check(
  273. 'A8b-other-modes-placeholders-are-reported-not-pinned',
  274. // 不断言具体块数:只要求"真面板 + 占位"加起来正好是本模式的 4 块面板
  275. // (即没有面板凭空消失或多出来),这样别的票推进不会再让本票假红。
  276. overviewReal + overviewPlaceholders === 4,
  277. `切到总体概览:真面板 ${overviewReal} 块 + 占位 ${overviewPlaceholders} 块 = 4(只汇报,不钉死谁接没接)`,
  278. )
  279. // 切回基础设施,后面的 B/C/D 组要在本模式的画面上跑。
  280. await page.evaluate(() => {
  281. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '基础设施')
  282. if (button) button.click()
  283. })
  284. await sleep(600)
  285. // 接口词汇不许漏到大屏上(票 05/11 各逮到过一次)。
  286. // 守卫面要覆盖**两类**词:状态/原因码(READY、NO_OBJECTS…)与**枚举值**(行业码、规模、权属…)。
  287. // 最初只查了前者,于是 `规模=medium` 这条英文枚举漏了过去 —— 真机走查才逮到。
  288. const leaks = [
  289. // 状态与原因码
  290. 'READY', 'NO_OBJECTS', 'NO_METRICS', 'SAMPLE_CURRENT', 'SOURCE_UNAVAILABLE',
  291. 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'EMPTY', 'STALE',
  292. // 枚举值(经 domain 直译后才该出现在界面上)
  293. 'GAS', 'WATER_SUPPLY', 'DRAINAGE', 'SUPPLY', 'FLOW', 'CAPACITY', 'PRESSURE', 'QUALITY',
  294. 'OVERLOAD', 'WATCH', 'NORMAL', 'CURRENT', 'OWNED', 'LEASED', 'ENTRUSTED',
  295. 'EMERGENCY_RESOURCE', 'HAZARD_SOURCE', 'PROTECTION_TARGET',
  296. 'large', 'medium', 'small',
  297. 'UNKNOWN', 'CURRENT_BASELINE', 'LAST_24_HOURS',
  298. ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.visibleText))
  299. check(
  300. 'A9-no-backend-vocabulary-on-screen',
  301. leaks.length === 0,
  302. leaks.length ? `界面出现后端代号:${leaks.join(', ')}` : `两栏可见文本零后端代号(查了 ${26} 个状态码与枚举值)`,
  303. )
  304. check('A10-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
  305. // ---- B 验收第 6 条:点可定位对象真的联动地图 ----
  306. const loaded = await advanceUntilSettled(page)
  307. const before = await readPanels(page)
  308. const locateTargets = (panelOf(before, 'operation')?.locatePoints || []).concat(panelOf(before, 'emergency')?.locatePoints || [])
  309. check(
  310. 'B1-panels-expose-locatable-points',
  311. locateTargets.length > 0 && loaded > 0,
  312. `可定位对象 ${locateTargets.length} 个;地图已加载 ${loaded} 个对象`,
  313. )
  314. if (locateTargets.length > 0) {
  315. // 取一个**可上图**的点位(MP-003 在样例里是 UNVERIFIED,不该被飞过去)。
  316. const target = locateTargets.find((code) => code === 'MP-001' || code === 'MP-002') || locateTargets[0]
  317. const expected = await page.evaluate((code) => {
  318. const dev = window.__cockpitMap
  319. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  320. const found = ((markers && markers.markers) || []).find((m) => m.code === code)
  321. return found ? { lon: found.longitude, lat: found.latitude } : null
  322. }, target)
  323. await page.evaluate((code) => {
  324. const button = document.querySelector(`[data-locate-point="${code}"]`)
  325. if (button) button.click()
  326. }, target)
  327. if (expected) await waitForCameraNear(page, expected)
  328. await sleep(900)
  329. const after = await readPanels(page)
  330. check(
  331. 'B2-locate-moves-camera',
  332. !!expected &&
  333. !!after.camera &&
  334. Math.abs(after.camera.lon - expected.lon) < 0.01 &&
  335. Math.abs(after.camera.lat - expected.lat) < 0.01,
  336. `相机中心 ${after.camera ? `${after.camera.lon.toFixed(5)},${after.camera.lat.toFixed(5)}` : 'null'}(目标 ${expected ? `${expected.lon},${expected.lat}` : '未知'})`,
  337. )
  338. check(
  339. 'B3-locate-opens-same-point-detail',
  340. after.hasCard && after.cardPointCode === target && after.detailLevel === 'point',
  341. `详情卡:level=${after.detailLevel} pointCode=${after.cardPointCode}(点的是 ${target})`,
  342. )
  343. check('B4-locate-success-shows-no-warning', after.locateResult === '', `成功后不给多余文字(result="${after.locateResult}")`)
  344. check(
  345. 'B5-markers-unchanged-by-locate',
  346. after.markers === before.markers,
  347. `定位前后地图标记数不变:${before.markers} → ${after.markers}(面板不往地图上加对象)`,
  348. )
  349. }
  350. // ---- C 可定位对象不可上图时:如实说明,不飞 ----
  351. // 样例里 MP-003 的坐标是 UNVERIFIED(票 07 判为不可上图),点它必须给说明而不是飞过去。
  352. // 它出现在**应急保障**的危险源条目上(运行信息面板只画第一个行业块,里面没有 MP-003)。
  353. const blockedTarget = (panelOf(before, 'emergency')?.locatePoints || []).find((code) => code === 'MP-003')
  354. if (blockedTarget) {
  355. // 注意:B 组刚打开过 MP-002 的详情卡,所以这里**不能**断言"没有卡片"——
  356. // 要断言的是"卡片没有被换成被拒的那个点位"(那才是"没飞过去"的证据)。
  357. const beforeBlocked = await readPanels(page)
  358. await page.evaluate((code) => {
  359. const button = document.querySelector(`[data-locate-point="${code}"]`)
  360. if (button) button.click()
  361. }, blockedTarget)
  362. await sleep(900)
  363. const blocked = await readPanels(page)
  364. check(
  365. 'C1-blocked-point-explains-and-does-not-fly',
  366. blocked.cardPointCode !== blockedTarget &&
  367. !!blocked.locateResult &&
  368. JSON.stringify(beforeBlocked.camera) === JSON.stringify(blocked.camera),
  369. `点不可上图的 ${blockedTarget}:给出说明「${blocked.locateFeedback}」,相机没动、详情卡仍停在 ${blocked.cardPointCode || '(无)'}`,
  370. )
  371. }
  372. // ---- D 展开/收起仍真实改变高度(票 01 的语义不回退) ----
  373. const expanded = await page.evaluate(() => {
  374. const panel = document.querySelector('[data-panel-blocks="archive"]')
  375. const shellPanels = Array.from(document.querySelectorAll('.cockpit-panel'))
  376. const own = shellPanels.find((p) => p.contains(panel))
  377. return own ? Math.round(own.getBoundingClientRect().height) : 0
  378. })
  379. await page.evaluate(() => {
  380. const panel = document.querySelector('[data-panel-blocks="archive"]')
  381. const own = Array.from(document.querySelectorAll('.cockpit-panel')).find((p) => p.contains(panel))
  382. const head = own && own.querySelector('.cockpit-panel__head')
  383. if (head) head.click()
  384. })
  385. await sleep(500)
  386. const collapsed = await page.evaluate(() => {
  387. const panel = document.querySelector('[data-panel-blocks="archive"]')
  388. const own = Array.from(document.querySelectorAll('.cockpit-panel')).find((p) => p.contains(panel))
  389. return own ? Math.round(own.getBoundingClientRect().height) : 0
  390. })
  391. check(
  392. 'D1-collapse-changes-height-not-just-visibility',
  393. collapsed > 0 && collapsed < expanded,
  394. `档案面板高 ${expanded} → ${collapsed}px(收起真的缩回标题栏高度)`,
  395. )
  396. await page.close()
  397. }
  398. // ================================================================ E 一块坏掉不遮其它三块
  399. //
  400. // 两个子场景都用**新开的页面**(`openPage` 的 `onlyPath` 直接选中一个端点):
  401. // 不用「现场改全局键再 reload」那套 —— `evaluateOnNewDocument` 在每次导航时都会重放,
  402. // reload 会把现场设置冲回去,那样测出来的其实是场景 A,断言就成了假绿。
  403. {
  404. // E1/E2:四个端点全坏 —— 四块都该说清楚,而不是白屏。
  405. const { page, errors } = await openPage('failed')
  406. const state = await readPanels(page)
  407. check(
  408. 'E1-all-infrastructure-panels-failed',
  409. state.panels.length === 4 && state.panels.every((p) => p.state !== 'success'),
  410. state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
  411. )
  412. check(
  413. 'E2-failed-panels-say-why-not-blank',
  414. state.panels.every((p) => p.placeholder.includes('加载失败') || p.placeholder.includes('不可用')),
  415. `失败措辞:${state.panels.map((p) => p.placeholder).join(' / ')}`,
  416. )
  417. check('E2b-failure-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
  418. await page.close()
  419. // E3/E4:只让**管理信息**这一个端点坏,其余三块必须照常画出真实内容(验收第 3 条的形态)。
  420. const { page: isolatedPage, errors: isolatedErrors } = await openPage('failed', '/infrastructure/management-information')
  421. const isolated = await readPanels(isolatedPage)
  422. const bad = isolated.panels.filter((p) => p.state !== 'success')
  423. check(
  424. 'E3-one-panel-failure-does-not-cover-others',
  425. bad.length === 1 &&
  426. bad[0].id === 'management' &&
  427. isolated.panels.filter((p) => p.state === 'success').length === 3,
  428. `${bad.map((p) => p.id).join(',') || '无'} 失败;其余 ${isolated.panels.filter((p) => p.state === 'success').length} 块成功`,
  429. )
  430. check(
  431. 'E4-good-panels-still-render-real-content',
  432. (panelOf(isolated, 'archive')?.kpis.length || 0) > 0 && (panelOf(isolated, 'emergency')?.items.length || 0) > 0,
  433. `档案 KPI ${panelOf(isolated, 'archive')?.kpis.length} 个、应急条目 ${panelOf(isolated, 'emergency')?.items.length} 条照常画出`,
  434. )
  435. check(
  436. 'E5-isolated-run-has-no-page-errors',
  437. isolatedErrors.length === 0,
  438. isolatedErrors.length ? isolatedErrors.join(' | ') : '无页面错误',
  439. )
  440. await isolatedPage.close()
  441. }
  442. // ================================================================ F 分段失败与空场景
  443. {
  444. const { page } = await openPage('infrastructure-partial')
  445. const state = await readPanels(page)
  446. const emergency = panelOf(state, 'emergency')
  447. // 按**段**断言(不是"某个选择器命中就行"):防护目标段该说"没取到",
  448. // 危险源与应急资源两段照常画出条目 —— 这正是"一段坏了不遮盖其它段"的形态。
  449. const targetSection = (emergency.listSections || []).find((s) => s.label === '防护目标')
  450. const hazardSection = (emergency.listSections || []).find((s) => s.label === '危险源')
  451. const resourceSection = (emergency.listSections || []).find((s) => s.label === '应急资源')
  452. check(
  453. 'F1-child-section-unavailable-is-distinct-from-empty',
  454. !!targetSection &&
  455. targetSection.stateText === '该段数据未取到' &&
  456. targetSection.rows === 0 &&
  457. !!hazardSection &&
  458. hazardSection.rows > 0 &&
  459. !!resourceSection &&
  460. resourceSection.rows > 0,
  461. `防护目标段「${targetSection ? targetSection.stateText : '(段缺失)'}」且 0 条;危险源 ${hazardSection ? hazardSection.rows : 0} 条、应急资源 ${resourceSection ? resourceSection.rows : 0} 条照常画出`,
  462. )
  463. await page.close()
  464. const { page: emptyPage } = await openPage('empty')
  465. const emptyState = await readPanels(emptyPage)
  466. check(
  467. 'F2-empty-scenario-explicit-placeholders',
  468. emptyState.panels.length === 4 &&
  469. emptyState.panels.every((p) => p.placeholder === '该面板当前没有数据'),
  470. `四块占位说明:${emptyState.panels.map((p) => p.placeholder).join(' / ')}`,
  471. )
  472. check(
  473. 'F3-empty-does-not-fake-zeros',
  474. emptyState.panels.every((p) => !p.kpis.some((k) => k.value === '0')),
  475. '空场景不显示伪造的 0 读数(缺值一律占位符)',
  476. )
  477. await emptyPage.close()
  478. }
  479. await browser.close()
  480. const failed = results.filter((item) => !item.pass)
  481. console.log('')
  482. console.log(`T17 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  483. if (failed.length) {
  484. console.log('未通过:')
  485. for (const item of failed) console.log(` · ${item.id}`)
  486. process.exit(1)
  487. }
  488. console.log('T17 基础设施模式面板走查全部通过。')