t11-equipment-detail-walkthrough.mjs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. /**
  2. * T11 真机走查:设备详情的六个分区按设备编码加载、分区独立成败(票 11)。
  3. *
  4. * 逻辑脚本(t11-equipment-detail.mjs)已经把分区映射、视图占位符、store 按编码取数、
  5. * 换设备竞态、分区独立与图片单值测透了;这里只补**只有在真机才成立**的部分:
  6. * 1. 选设备卡片后,界面**真的**按这台设备的编码把六个分区画出来(DOM 实证,不是读 store);
  7. * 2. 分区级部分失败时,坏掉的那块显示失败措辞,**好的三块仍然画出真实内容**
  8. * —— 这是本票验收第 3 条唯一在真机上才能验的形态;
  9. * 3. 空 / 过期场景下每块分区都有明确 UI(验收第 4 条);
  10. * 4. 最新图片只画出**一张** `<img>`(验收第 5 条);
  11. * 5. 打开设备详情不改动地图上的标记数量(票 10 的常驻语义不回退)。
  12. *
  13. * 运行(需先起 `yarn dev`;本机 dev 端口见启动输出,默认 8000/8001):
  14. * node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs
  15. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  16. *
  17. * **场景注入的关键**:用 `__COCKPIT_MOCK_ONLY__` 把异常样例**只**施加在设备详情聚合这一个
  18. * 端点上,地图与点位摘要保持 success。否则全局 `empty` 会把地图也清空 —— 那就没有可点的
  19. * 标记,走查会退化成"点不到东西"的假失败(票 08/09 都踩过这类坑)。这样每个场景点下去
  20. * 都是同一台设备的六个分区,只有聚合内容变了,对比才有意义。
  21. *
  22. * 演示开关(宿主全局键注入,不改代码重新构建;场景观察最直观的一屏是 equipment-partial):
  23. * `__COCKPIT_MOCK_SCENARIO__='equipment-partial'` + `__COCKPIT_MOCK_ONLY__` 取设备详情聚合路径
  24. * —— 档案/工单/图片各自失败,指标/预警/报警照常成功(一块坏了不遮其它块)
  25. * `...SCENARIO__='empty'` —— 六块都是"没有数据"
  26. * `...SCENARIO__='stale'` —— 指标块过期但保留旧值
  27. * (`__COCKPIT_MOCK_ONLY__` 的确切写法见下方 `AGGREGATE_ONLY` 常量)
  28. */
  29. import { join } from 'node:path'
  30. import { tmpdir } from 'node:os'
  31. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  32. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  33. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  34. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  35. /** 只让设备详情聚合走异常样例,地图与点位摘要保持 success(见文件头说明)。 */
  36. const AGGREGATE_ONLY = '/equipments/*/detail-aggregate'
  37. const results = []
  38. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  39. function check(id, pass, evidence) {
  40. results.push({ id, pass })
  41. const rest = evidence === undefined ? '' : String(evidence)
  42. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  43. }
  44. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  45. const puppeteer = (await import(PUPPETEER)).default
  46. const browser = await puppeteer.launch({
  47. executablePath: CHROME,
  48. headless: true,
  49. userDataDir: join(tmpdir(), 'cockpit-t11-profile'),
  50. defaultViewport: { width: 1680, height: 950 },
  51. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  52. })
  53. console.log(`Chrome: ${await browser.version()}`)
  54. /**
  55. * 一次读全:设备详情浮层 DOM(层级/表头/六块分区/图片)+ 地图侧口径(标记计划、宿主 dataset)。
  56. * 分区内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
  57. */
  58. const readDetail = (page) =>
  59. page.evaluate(() => {
  60. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  61. const card = document.querySelector('[data-point-detail]')
  62. const text = (selector) => {
  63. const el = card && card.querySelector(selector)
  64. return el ? el.textContent.trim() : ''
  65. }
  66. const dataset = (el) => (el && el.dataset) || {}
  67. const dev = window.__cockpitMap
  68. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  69. return {
  70. hasCard: !!card,
  71. level: card ? card.dataset.detailLevel : '',
  72. pointCode: card ? card.dataset.pointCode : '',
  73. deviceCode: card ? card.dataset.deviceCode : '',
  74. deviceName: text('[data-device-name]'),
  75. deviceCodeShown: text('[data-device-code-value]'),
  76. deviceLoading: !!text('[data-device-loading]'),
  77. deviceUnavailable: text('[data-device-unavailable]'),
  78. hasRetry: !!(card && card.querySelector('[data-device-retry]')),
  79. hasBack: !!(card && card.querySelector('[data-detail-back]')),
  80. // ---- 六个分区:每块自带状态、内容与占位说明 ----
  81. sections: card
  82. ? Array.from(card.querySelectorAll('[data-equipment-section]')).map((el) => ({
  83. name: el.dataset.equipmentSection || '',
  84. // 分区容器自己的状态(data-panel-*,避免与 SectionStateTag 的 data-section-* 撞名)
  85. state: el.dataset.panelState || '',
  86. retained: el.dataset.panelRetained === 'true',
  87. // 分区自己的状态标签(SectionStateTag 的 data-section-*)
  88. tagName: dataset(el.querySelector('[data-section-name]')).sectionName || '',
  89. tagState: dataset(el.querySelector('[data-section-state]')).sectionState || '',
  90. tagText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
  91. title: ((el.querySelector('.equipment-section__title') || {}).textContent || '').trim(),
  92. rows: el.querySelectorAll('[data-archive-row], [data-metric], [data-event], [data-work-order]').length,
  93. imageCount: el.querySelectorAll('[data-latest-image] img').length,
  94. placeholder: ((el.querySelector('[data-section-placeholder]') || {}).textContent || '').trim(),
  95. // 缺失说明:界面上只有条数与 tooltip(字段路径不进可见文案)
  96. missingText: ((el.querySelector('[data-section-missing]') || {}).textContent || '').trim(),
  97. missingCount: Number(dataset(el.querySelector('[data-section-missing]')).missingCount || 0),
  98. missingTitle: (el.querySelector('[data-section-missing]') || {}).title || '',
  99. }))
  100. : [],
  101. archiveValues: card ? Array.from(card.querySelectorAll('[data-archive-row] dd')).map((el) => el.textContent.trim()) : [],
  102. metricValues: card ? Array.from(card.querySelectorAll('[data-metric-value]')).map((el) => el.textContent.trim()) : [],
  103. eventLevels: card ? Array.from(card.querySelectorAll('[data-event-level]')).map((el) => el.textContent.trim()) : [],
  104. workOrderStates: card
  105. ? Array.from(card.querySelectorAll('[data-work-order]')).map((el) => ({
  106. code: el.dataset.workOrder || '',
  107. state: el.dataset.workOrderState || '',
  108. text: ((el.querySelector('.equipment-section__item-name') || {}).textContent || '').trim(),
  109. }))
  110. : [],
  111. imageSrcs: card ? Array.from(card.querySelectorAll('[data-latest-image] img')).map((el) => el.getAttribute('src') || '') : [],
  112. // ---- 地图侧 ----
  113. engine: (host && host.dataset.mapEngine) || '',
  114. summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
  115. summaryPhase: (host && host.dataset.mapSummary) || '',
  116. dsMarkers: Number((host && host.dataset.mapMarkers) || 0),
  117. dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
  118. picked: (host && host.dataset.mapPicked) || '',
  119. entityCount: markers ? markers.onMap : -1,
  120. cardHosts: document.querySelectorAll('[data-point-detail]').length,
  121. mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  122. }
  123. })
  124. /** 打开页面并走真实的进入引导(点一下「点击进入系统」)。 */
  125. async function openPage(scenario) {
  126. const page = await browser.newPage()
  127. const errors = []
  128. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  129. page.on('console', (m) => {
  130. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  131. })
  132. await page.evaluateOnNewDocument(
  133. (name) => {
  134. window.__COCKPIT_MOCK_SCENARIO__ = name
  135. window.__COCKPIT_MOCK_ONLY__ = '/equipments/*/detail-aggregate'
  136. window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
  137. },
  138. scenario,
  139. )
  140. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  141. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  142. await page
  143. .waitForFunction(
  144. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  145. { timeout: 40000 },
  146. )
  147. .catch(() => {})
  148. if (await page.$('[data-cockpit-intro]')) {
  149. await page.click('[data-cockpit-intro]')
  150. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  151. }
  152. await sleep(1500)
  153. return { page, errors }
  154. }
  155. /** 点「加载下一页」直到翻完(地图上的监测点标记要等它出现)。 */
  156. async function advanceUntilSettled(page, maxSteps = 12) {
  157. for (let step = 0; step < maxSteps; step += 1) {
  158. const state = await readDetail(page)
  159. if (['complete', 'empty', 'failed', 'expired'].includes(state.summaryPhase)) break
  160. const clicked = await page.evaluate(() => {
  161. const button = document.querySelector('[data-map-summary-action]')
  162. if (!button || button.disabled) return false
  163. button.click()
  164. return true
  165. })
  166. if (!clicked) break
  167. const before = state.summaryLoaded
  168. await page
  169. .waitForFunction(
  170. (previous) => {
  171. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  172. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  173. const phase = (host && host.dataset.mapSummary) || ''
  174. return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
  175. },
  176. { timeout: 10000 },
  177. before,
  178. )
  179. .catch(() => {})
  180. await sleep(350)
  181. }
  182. return readDetail(page)
  183. }
  184. /** 等相机真的落在目标上(阻尼飞行途中点屏幕中心必然点空)。 */
  185. async function waitForCameraNear(page, target, timeout = 25000) {
  186. await page
  187. .waitForFunction(
  188. ({ lon, lat }) => {
  189. const dev = window.__cockpitMap
  190. const state = dev && dev.getCameraState ? dev.getCameraState() : null
  191. const shown = state && state.shownPivot
  192. return !!shown && Math.abs(shown.lon - lon) < 2e-4 && Math.abs(shown.lat - lat) < 2e-4
  193. },
  194. { timeout },
  195. { lon: target.lon, lat: target.lat },
  196. )
  197. .catch(() => {})
  198. }
  199. /** 相机中心对准第一个监测点标记。 */
  200. async function focusOnPoint(page) {
  201. const target = await page.evaluate(() => {
  202. const dev = window.__cockpitMap
  203. const marker = dev.getPointMarkers().markers.find((item) => item.kind === 'monitoring-point')
  204. if (!marker) return null
  205. dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
  206. return { lon: marker.longitude, lat: marker.latitude, code: marker.code }
  207. })
  208. if (target) await waitForCameraNear(page, target)
  209. return target
  210. }
  211. /** 用引擎拾取在相机中心附近找监测点标记的真实屏幕坐标(贴地标记与中心有高程差)。 */
  212. async function locatePoint(page, radius = 300, stride = 10) {
  213. return page.evaluate(
  214. ([max, step]) => {
  215. const dev = window.__cockpitMap
  216. const canvas = document.querySelector('.cockpit-shell__map canvas')
  217. const center = dev.projectCameraPivot()
  218. if (!center || !canvas) return { x: 0, y: 0, code: '' }
  219. for (let dy = -max; dy <= max; dy += step) {
  220. for (let dx = -max; dx <= max; dx += step) {
  221. const x = Math.round(center.x + dx)
  222. const y = Math.round(center.y + dy)
  223. const hitElement = document.elementFromPoint(x, y)
  224. if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue
  225. const hit = dev.pickPoint(x, y)
  226. if (hit && hit.kind === 'monitoring-point') return { x, y, dx, dy, code: hit.code }
  227. }
  228. }
  229. return { x: 0, y: 0, dx: 0, dy: 0, code: '' }
  230. },
  231. [radius, stride],
  232. )
  233. }
  234. /**
  235. * 打开设备详情:真实鼠标点监测点标记 → 点位摘要 → 点第一张设备卡片 → 设备级。
  236. * 这条路径是 CONTEXT.md 的「两级详情」正路,也是用户实际会走的。
  237. */
  238. async function openDeviceDetail(scenario) {
  239. const { page, errors } = await openPage(scenario)
  240. await advanceUntilSettled(page)
  241. const target = await focusOnPoint(page)
  242. const locatedPoint = await locatePoint(page)
  243. if (await page.$('[data-detail-close]')) {
  244. await page.click('[data-detail-close]')
  245. await sleep(250)
  246. }
  247. if (locatedPoint.code) {
  248. await page.mouse.click(locatedPoint.x, locatedPoint.y)
  249. await sleep(700)
  250. }
  251. const summary = await readDetail(page)
  252. const cards = await page.evaluate(() =>
  253. Array.from(document.querySelectorAll('[data-device-card]')).map((el) => el.dataset.deviceCode || ''),
  254. )
  255. if (cards.length) {
  256. await page.click('[data-device-card="0"]')
  257. // 等六块分区真的画出来(聚合要发一次请求)。
  258. await page.waitForSelector('[data-equipment-section]', { timeout: 10000 }).catch(() => {})
  259. await sleep(600)
  260. }
  261. return { page, errors, target, locatedPoint, summary, cards, detail: await readDetail(page) }
  262. }
  263. const SECTION_ORDER = ['archive', 'metrics', 'warnings', 'alarms', 'workOrders', 'latestImage']
  264. const sectionOf = (detail, name) => detail.sections.find((item) => item.name === name) || null
  265. try {
  266. // ================================================================ A 成功场景:六块分区按设备编码画出来
  267. {
  268. const { page, errors, cards, detail } = await openDeviceDetail('success')
  269. const code = cards[0] || ''
  270. check(
  271. 'A1-device-card-enters-equipment-level',
  272. !!code && detail.hasCard && detail.level === 'equipment' && detail.deviceCode === code,
  273. code ? `选设备卡片 ${code} → 层级 ${detail.level}` : '点位摘要里没有设备卡片',
  274. )
  275. check(
  276. 'A2-six-sections-rendered-in-order',
  277. detail.sections.length === 6 && detail.sections.map((item) => item.name).join(',') === SECTION_ORDER.join(','),
  278. `六块分区:${detail.sections.map((item) => item.title).join(' / ')}`,
  279. )
  280. check(
  281. 'A3-every-section-has-its-own-status-tag',
  282. detail.sections.length === 6 &&
  283. detail.sections.every((item) => !!item.tagName && !!item.tagState && !!item.tagText) &&
  284. detail.sections.every((item) => item.tagName === item.name),
  285. detail.sections.map((item) => `${item.name}:${item.tagState}/${item.tagText}`).join(' · '),
  286. )
  287. check(
  288. 'A4-loaded-by-device-code',
  289. detail.deviceCode === code && detail.deviceCodeShown === code && detail.deviceLoading === false,
  290. `浮层表头 ${detail.deviceCodeShown} = 选中的设备 ${code},且已不在加载态`,
  291. )
  292. const archive = sectionOf(detail, 'archive')
  293. const metrics = sectionOf(detail, 'metrics')
  294. const warnings = sectionOf(detail, 'warnings')
  295. const alarms = sectionOf(detail, 'alarms')
  296. const orders = sectionOf(detail, 'workOrders')
  297. const image = sectionOf(detail, 'latestImage')
  298. check(
  299. 'A5-archive-has-real-rows',
  300. archive && archive.rows >= 10 && detail.archiveValues.some((value) => value.includes('污水环境监测设备')),
  301. `档案 ${archive && archive.rows} 行,含设备名`,
  302. )
  303. check(
  304. 'A6-metrics-have-readouts-including-real-zero',
  305. metrics && metrics.rows >= 2 && detail.metricValues.length >= 2 && detail.metricValues.some((value) => value.startsWith('0')),
  306. `指标 ${metrics && metrics.rows} 条:${detail.metricValues.join(' / ')}(含真 0 读数,没被当成缺失)`,
  307. )
  308. check(
  309. 'A7-warnings-and-alarms-listed',
  310. warnings && warnings.rows >= 1 && alarms && alarms.rows >= 1 && detail.eventLevels.every((level) => level.endsWith('级')),
  311. `预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条 · 等级 ${detail.eventLevels.join('/')}`,
  312. )
  313. check(
  314. 'A8-work-orders-show-state-name',
  315. orders && orders.rows >= 1 && detail.workOrderStates.every((order) => order.text.includes('处理中')),
  316. `工单 ${orders && orders.rows} 张:${detail.workOrderStates.map((order) => `${order.code}=${order.text}`).join(' · ')}`,
  317. )
  318. check(
  319. 'A9-latest-image-is-exactly-one',
  320. image && image.imageCount === 1 && detail.imageSrcs.length === 1 && !!detail.imageSrcs[0],
  321. `最新图片分区画出 ${detail.imageSrcs.length} 张 <img>:${detail.imageSrcs.join(', ')}`,
  322. )
  323. check(
  324. 'A10-detail-does-not-rebuild-map',
  325. detail.cardHosts === 1 && detail.mapHosts === 1 && detail.entityCount === detail.dsPlan,
  326. `浮层 ${detail.cardHosts} 个 · 地图宿主 ${detail.mapHosts} 个 · 实体 ${detail.entityCount} = 计划 ${detail.dsPlan}(地图节点未重建)`,
  327. )
  328. // 票 05 走过一次的坑:界面漏出内部代号。真机取一份**整卡可见文本**来查,
  329. // 而不是查单个 data-* 节点 —— 漏在标题、meta 行或占位说明里同样要被抓到。
  330. const cardText = await page.evaluate(() => (document.querySelector('[data-point-detail]') || {}).innerText || '')
  331. const codeLeaks = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY']
  332. .filter((code) => new RegExp(`\\b${code}\\b`).test(cardText))
  333. // 字段路径(`archive.deviceName` 这类)也属接口词汇,不该出现在可见文案里。
  334. const pathLeaks = (cardText.match(/\b[a-zA-Z]+\.[a-zA-Z]+\b/g) || []).filter((token) => !token.includes('.webp') && !token.includes('.js'))
  335. check(
  336. 'A10b-visible-text-has-no-internal-codes',
  337. codeLeaks.length === 0 && pathLeaks.length === 0,
  338. codeLeaks.length || pathLeaks.length
  339. ? `可见文案泄漏:代号 ${codeLeaks.join(',') || '无'} · 字段路径 ${pathLeaks.join(',') || '无'}`
  340. : '整卡可见文本零内部代号、零字段路径(缺失只报条数,路径在 tooltip 里)',
  341. )
  342. check('A11-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  343. await page.close()
  344. }
  345. // ================================================================ B 分区级部分失败:坏块不遮好块
  346. {
  347. const { page, errors, cards, detail } = await openDeviceDetail('equipment-partial')
  348. if (!cards.length) {
  349. check('B0-partial-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  350. } else {
  351. const archive = sectionOf(detail, 'archive')
  352. const metrics = sectionOf(detail, 'metrics')
  353. const warnings = sectionOf(detail, 'warnings')
  354. const alarms = sectionOf(detail, 'alarms')
  355. const orders = sectionOf(detail, 'workOrders')
  356. const image = sectionOf(detail, 'latestImage')
  357. check(
  358. 'B1-broken-sections-report-failure',
  359. archive && archive.state === 'failed' && orders && orders.state === 'failed' && image && image.state === 'failed',
  360. `坏块:档案=${archive && archive.state} 工单=${orders && orders.state} 图片=${image && image.state}`,
  361. )
  362. check(
  363. 'B2-broken-sections-say-so-in-words',
  364. archive && archive.placeholder.includes('加载失败') && orders.placeholder.includes('加载失败') && image.placeholder.length > 0,
  365. `坏块措辞:「${archive && archive.placeholder}」`,
  366. )
  367. check(
  368. 'B3-healthy-sections-still-render-real-content',
  369. metrics && metrics.state === 'success' && metrics.rows >= 2 && detail.metricValues.length >= 2 &&
  370. warnings && warnings.state === 'success' && warnings.rows >= 1 &&
  371. alarms && alarms.state === 'success' && alarms.rows >= 1,
  372. `好块照常:指标 ${metrics && metrics.rows} 条(${detail.metricValues.join('/')})· 预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条`,
  373. )
  374. check(
  375. 'B4-failure-does-not-blank-the-card',
  376. detail.sections.length === 6 && detail.level === 'equipment' && detail.deviceCode === cards[0],
  377. `六块分区都在(${detail.sections.map((item) => item.state).join('/')}),浮层没白屏也没自动关掉`,
  378. )
  379. check(
  380. 'B5-guard-has-teeth',
  381. detail.sections.filter((item) => item.state === 'failed').length === 3 &&
  382. detail.sections.filter((item) => item.state === 'success').length === 3,
  383. '恰好 3 坏 3 好(因此 B3「好块仍在」不是恒真)',
  384. )
  385. check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  386. }
  387. await page.close()
  388. }
  389. // ================================================================ C 空场景:每块都有明确 UI
  390. {
  391. const { page, errors, cards, detail } = await openDeviceDetail('empty')
  392. if (!cards.length) {
  393. check('C0-empty-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  394. } else {
  395. check(
  396. 'C1-empty-sections-all-say-no-data',
  397. detail.sections.length === 6 &&
  398. detail.sections.every((item) => item.state === 'empty') &&
  399. detail.sections.every((item) => item.placeholder === '该分区当前没有数据'),
  400. detail.sections.map((item) => `${item.name}=${item.state}`).join(' · '),
  401. )
  402. check(
  403. 'C2-empty-does-not-fake-zero-values',
  404. detail.metricValues.length === 0 && detail.archiveValues.length === 0 && detail.workOrderStates.length === 0 && detail.imageSrcs.length === 0,
  405. '空场景下没有任何伪造的 0 / 空行 / 空图',
  406. )
  407. check('C3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  408. }
  409. await page.close()
  410. }
  411. // ================================================================ D 过期场景:有值 + 过期标识
  412. {
  413. const { page, errors, cards, detail } = await openDeviceDetail('stale')
  414. if (!cards.length) {
  415. check('D0-stale-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  416. } else {
  417. const metrics = sectionOf(detail, 'metrics')
  418. const archive = sectionOf(detail, 'archive')
  419. check(
  420. 'D1-stale-section-flagged-and-valued',
  421. metrics && metrics.state === 'stale' && metrics.retained === true && metrics.rows >= 2 && detail.metricValues.length >= 2,
  422. `指标块 ${metrics && metrics.state}(retained=${metrics && metrics.retained})但仍画出 ${detail.metricValues.length} 条读数`,
  423. )
  424. check(
  425. 'D2-stale-not-confused-with-empty',
  426. metrics && metrics.placeholder === '' && metrics.tagText.includes('过期'),
  427. `过期块说的是「${metrics && metrics.tagText}」,不是「无数据」`,
  428. )
  429. check(
  430. 'D3-stale-does-not-affect-other-sections',
  431. archive && archive.state === 'success' && archive.rows >= 10,
  432. `档案块照常 ${archive && archive.state}(${archive && archive.rows} 行)—— 过期不牵连`,
  433. )
  434. check('D4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  435. }
  436. await page.close()
  437. }
  438. // ================================================================ E 整包失败:一句实话 + 重试入口
  439. {
  440. const { page, errors, cards, detail } = await openDeviceDetail('failed')
  441. if (!cards.length) {
  442. check('E0-failed-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  443. } else {
  444. check(
  445. 'E1-whole-failure-says-one-truthful-thing',
  446. detail.level === 'equipment' && !!detail.deviceUnavailable && detail.sections.length === 0,
  447. `整包失败时说的是「${detail.deviceUnavailable}」(不渲染六块,因此不会逐块谎报"其它分区不受影响")`,
  448. )
  449. check(
  450. 'E2-whole-failure-offers-retry',
  451. detail.hasRetry === true && detail.deviceLoading === false,
  452. '给了「重新加载」入口,不是死胡同',
  453. )
  454. check(
  455. 'E3-whole-failure-does-not-blank-or-close',
  456. detail.hasCard === true && detail.cardHosts === 1 && detail.deviceCode === cards[0],
  457. `浮层仍在、表头仍带设备 ${detail.deviceCode}(没白屏、没自动关窗)`,
  458. )
  459. check('E4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  460. }
  461. await page.close()
  462. }
  463. } finally {
  464. await browser.close()
  465. }
  466. const failed = results.filter((item) => !item.pass)
  467. console.log(`\nT11 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
  468. process.exit(failed.length ? 1 : 0)