t12-work-order-timeline-walkthrough.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. /**
  2. * T12 真机走查:工单七态与动作轨迹真的画在了设备详情里(票 12)。
  3. *
  4. * 逻辑脚本(t12-work-order-timeline.mjs)已经把七态字典、动作判定(回退/取消/同态/认不出的码)、
  5. * 轨迹组装、七态路径视图与分区独立测透了;这里只补**只有在真机上才成立**的部分:
  6. * 1. 工单面板里**每张工单真的挂着它自己那条**时间线(DOM 实证,不是读 store);
  7. * 2. 七态**七个节点一个不少**地画出来,且驳回**没有**多出第八个节点;
  8. * 3. 回退那一行在界面上带可见的「回退」标记(不是只靠颜色 —— 灰度下也要读得出);
  9. * 4. 轨迹分区失败时说的是「没取到」,**不是**「没有动作记录」(两句不同的话),
  10. * 且七态路径**照常**画("这张工单现在在哪一态"不因轨迹失败而丢);
  11. * 5. 整屏可见文本零内部代号(票 05/11 的同一条回归)。
  12. *
  13. * 运行(需先起 `yarn dev`;端口见启动输出,默认 8000):
  14. * node .scratch/cockpit-shell-panel-layout/verify/t12-work-order-timeline-walkthrough.mjs
  15. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  16. *
  17. * **场景注入的关键**(与 t11 同一套):用 `__COCKPIT_MOCK_ONLY__` 把样例**只**施加在设备详情
  18. * 聚合这一个端点上,地图与点位摘要保持 success —— 否则没有可点的标记,走查会退化成
  19. * "点不到东西"的假失败(票 08/09/11 都踩过这类坑)。
  20. *
  21. * 演示开关(宿主全局键注入,不改代码重新构建):
  22. * `__COCKPIT_MOCK_SCENARIO__='work-order-timeline'` 三张工单各一条轨迹(推进/回退/取消)
  23. * `__COCKPIT_MOCK_SCENARIO__='work-order-timeline-failed'` 工单列表成功、只有轨迹分区失败
  24. */
  25. import { join } from 'node:path'
  26. import { tmpdir } from 'node:os'
  27. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  28. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  29. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  30. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  31. /** 只让设备详情聚合走样例,地图与点位摘要保持 success(见文件头说明)。 */
  32. const AGGREGATE_ONLY = '/equipments/*/detail-aggregate'
  33. const results = []
  34. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  35. function check(id, pass, evidence) {
  36. results.push({ id, pass })
  37. const rest = evidence === undefined ? '' : String(evidence)
  38. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  39. }
  40. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  41. const puppeteer = (await import(PUPPETEER)).default
  42. const browser = await puppeteer.launch({
  43. executablePath: CHROME,
  44. headless: true,
  45. userDataDir: join(tmpdir(), 'cockpit-t12-profile'),
  46. defaultViewport: { width: 1680, height: 950 },
  47. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  48. })
  49. console.log(`Chrome: ${await browser.version()}`)
  50. /**
  51. * 从 DOM 读工单面板的时间线:每张工单一条、每条的七态路径与动作轨迹。
  52. * 一切从 DOM 读 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
  53. */
  54. const readTimelines = (page) =>
  55. page.evaluate(() => {
  56. const card = document.querySelector('[data-point-detail]')
  57. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  58. const section = card && card.querySelector('[data-equipment-section="workOrders"]')
  59. const text = (el) => (el ? el.textContent.trim() : '')
  60. return {
  61. hasCard: !!card,
  62. level: card ? card.dataset.detailLevel : '',
  63. hasOrderSection: !!section,
  64. // 工单面板自己(列表)的分区状态
  65. orderPanelState: section ? section.dataset.panelState : '',
  66. // ---- 每张工单 ----
  67. orders: section
  68. ? Array.from(section.querySelectorAll('[data-work-order]')).map((el) => ({
  69. code: el.dataset.workOrder || '',
  70. state: el.dataset.workOrderState || '',
  71. name: text(el.querySelector('.equipment-section__item-name')),
  72. // 这张工单下面挂的时间线(票 12 验收第 2 条:每张工单有独立时间线)
  73. timelines: el.querySelectorAll('[data-work-order-timeline]').length,
  74. timelineCode: (el.querySelector('[data-work-order-timeline]') || {}).dataset?.woCode || '',
  75. pathNodes: Array.from(el.querySelectorAll('[data-wo-path-node]')).map((node) => ({
  76. code: node.dataset.woPathNode || '',
  77. name: node.dataset.woPathName || '',
  78. state: node.dataset.woPathState || '',
  79. off: node.dataset.woPathOff === 'true',
  80. })),
  81. steps: Array.from(el.querySelectorAll('[data-wo-step]')).map((step) => ({
  82. action: step.dataset.woStepAction || '',
  83. state: step.dataset.woStepState || '',
  84. text: step.textContent.replace(/\s+/g, ' ').trim(),
  85. rollback: !!step.querySelector('[data-wo-rollback]'),
  86. })),
  87. rollbackRows: el.querySelectorAll('[data-wo-rollback]').length,
  88. missingNote: text(el.querySelector('[data-wo-timeline-missing]')),
  89. }))
  90. : [],
  91. placeholder: text(section && section.querySelector('[data-wo-timeline-placeholder]')),
  92. missingNotes: section ? Array.from(section.querySelectorAll('[data-wo-timeline-missing]')).map((el) => el.textContent.trim()) : [],
  93. // ---- 地图侧(证明打开详情没重建地图)----
  94. mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  95. engine: (host && host.dataset.mapEngine) || '',
  96. dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
  97. cardText: card ? card.innerText || '' : '',
  98. }
  99. })
  100. /** 打开页面并走真实的进入引导(点一下「点击进入系统」)。 */
  101. async function openPage(scenario) {
  102. const page = await browser.newPage()
  103. const errors = []
  104. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  105. page.on('console', (m) => {
  106. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  107. })
  108. await page.evaluateOnNewDocument(
  109. ({ name, only }) => {
  110. window.__COCKPIT_MOCK_SCENARIO__ = name
  111. window.__COCKPIT_MOCK_ONLY__ = only
  112. window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
  113. },
  114. { name: scenario, only: AGGREGATE_ONLY },
  115. )
  116. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  117. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  118. await page
  119. .waitForFunction(
  120. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  121. { timeout: 40000 },
  122. )
  123. .catch(() => {})
  124. if (await page.$('[data-cockpit-intro]')) {
  125. await page.click('[data-cockpit-intro]')
  126. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  127. }
  128. await sleep(1500)
  129. return { page, errors }
  130. }
  131. /** 点「加载下一页」直到翻完(地图上的监测点标记要等它出现)。 */
  132. async function advanceUntilSettled(page, maxSteps = 12) {
  133. for (let step = 0; step < maxSteps; step += 1) {
  134. const state = await page.evaluate(() => {
  135. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  136. return { phase: (host && host.dataset.mapSummary) || '', loaded: Number((host && host.dataset.mapSummaryLoaded) || 0) }
  137. })
  138. if (['complete', 'empty', 'failed', 'expired'].includes(state.phase)) break
  139. const clicked = await page.evaluate(() => {
  140. const button = document.querySelector('[data-map-summary-action]')
  141. if (!button || button.disabled) return false
  142. button.click()
  143. return true
  144. })
  145. if (!clicked) break
  146. await page
  147. .waitForFunction(
  148. (previous) => {
  149. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  150. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  151. const phase = (host && host.dataset.mapSummary) || ''
  152. return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
  153. },
  154. { timeout: 10000 },
  155. state.loaded,
  156. )
  157. .catch(() => {})
  158. await sleep(350)
  159. }
  160. }
  161. /** 等相机真的落在目标上(阻尼飞行途中点屏幕中心必然点空)。 */
  162. async function waitForCameraNear(page, target, timeout = 25000) {
  163. await page
  164. .waitForFunction(
  165. ({ lon, lat }) => {
  166. const dev = window.__cockpitMap
  167. const state = dev && dev.getCameraState ? dev.getCameraState() : null
  168. const shown = state && state.shownPivot
  169. return !!shown && Math.abs(shown.lon - lon) < 2e-4 && Math.abs(shown.lat - lat) < 2e-4
  170. },
  171. { timeout },
  172. { lon: target.lon, lat: target.lat },
  173. )
  174. .catch(() => {})
  175. }
  176. /** 相机中心对准第一个监测点标记。 */
  177. async function focusOnPoint(page) {
  178. const target = await page.evaluate(() => {
  179. const dev = window.__cockpitMap
  180. const marker = dev.getPointMarkers().markers.find((item) => item.kind === 'monitoring-point')
  181. if (!marker) return null
  182. dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
  183. return { lon: marker.longitude, lat: marker.latitude, code: marker.code }
  184. })
  185. if (target) await waitForCameraNear(page, target)
  186. return target
  187. }
  188. /** 用引擎拾取在相机中心附近找监测点标记的真实屏幕坐标(贴地标记与中心有高程差)。 */
  189. async function locatePoint(page, radius = 300, stride = 10) {
  190. return page.evaluate(
  191. ([max, step]) => {
  192. const dev = window.__cockpitMap
  193. const canvas = document.querySelector('.cockpit-shell__map canvas')
  194. const center = dev.projectCameraPivot()
  195. if (!center || !canvas) return { x: 0, y: 0, code: '' }
  196. for (let dy = -max; dy <= max; dy += step) {
  197. for (let dx = -max; dx <= max; dx += step) {
  198. const x = Math.round(center.x + dx)
  199. const y = Math.round(center.y + dy)
  200. const hitElement = document.elementFromPoint(x, y)
  201. if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue
  202. const hit = dev.pickPoint(x, y)
  203. if (hit && hit.kind === 'monitoring-point') return { x, y, dx, dy, code: hit.code }
  204. }
  205. }
  206. return { x: 0, y: 0, dx: 0, dy: 0, code: '' }
  207. },
  208. [radius, stride],
  209. )
  210. }
  211. /**
  212. * 打开设备详情(走 CONTEXT.md 的「两级详情」正路:点标记 → 点位摘要 → 点设备卡片)。
  213. * 到设备级后多等一拍:工单面板的时间线是随聚合一起画的,等它真的出现。
  214. */
  215. async function openDeviceDetail(scenario) {
  216. const { page, errors } = await openPage(scenario)
  217. await advanceUntilSettled(page)
  218. await focusOnPoint(page)
  219. const locatedPoint = await locatePoint(page)
  220. if (await page.$('[data-detail-close]')) {
  221. await page.click('[data-detail-close]')
  222. await sleep(250)
  223. }
  224. if (locatedPoint.code) {
  225. await page.mouse.click(locatedPoint.x, locatedPoint.y)
  226. await sleep(700)
  227. }
  228. const cards = await page.evaluate(() =>
  229. Array.from(document.querySelectorAll('[data-device-card]')).map((el) => el.dataset.deviceCode || ''),
  230. )
  231. if (cards.length) {
  232. await page.click('[data-device-card="0"]')
  233. await page.waitForSelector('[data-equipment-section]', { timeout: 10000 }).catch(() => {})
  234. // 时间线要跟聚合一起到(等它比只等"分区存在"更严)。
  235. await page.waitForSelector('[data-work-order-timeline], [data-wo-timeline-placeholder]', { timeout: 10000 }).catch(() => {})
  236. await sleep(600)
  237. }
  238. return { page, errors, cards, view: await readTimelines(page) }
  239. }
  240. /** 整屏可见文本里的内部代号(票 05/11 的泄漏回归)。 */
  241. const REASON_CODES = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SOURCE_SECTION_FAILED', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY']
  242. try {
  243. // ================================================================ A 三张工单各一条独立时间线
  244. {
  245. const { page, errors, cards, view } = await openDeviceDetail('work-order-timeline')
  246. if (!cards.length) {
  247. check('A0-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  248. } else {
  249. check(
  250. 'A1-work-order-panel-lists-three-orders',
  251. view.hasOrderSection && view.orders.length === 3,
  252. `工单面板画出 ${view.orders.length} 张:${view.orders.map((o) => `${o.code}(${o.name})`).join(' · ')}`,
  253. )
  254. // 验收第 2 条:每张工单有独立时间线 —— 数量与工单数相等,且编码一一对上。
  255. check(
  256. 'A2-every-order-has-its-own-timeline',
  257. view.orders.every((order) => order.timelines === 1) &&
  258. view.orders.every((order) => order.timelineCode === order.code) &&
  259. view.orders.length === 3,
  260. view.orders.map((o) => `${o.code}→${o.timelineCode}`).join(' · '),
  261. )
  262. // 条数各不相同 ⇒ 不是同一份轨迹抄三遍(这一刻意在对齐逻辑闸门的 E2)。
  263. const counts = view.orders.map((order) => order.steps.length)
  264. check(
  265. 'A3-tracks-are-not-the-same-one-copied',
  266. new Set(counts).size === 3 && counts.includes(6) && counts.includes(5) && counts.includes(3),
  267. `三张的步数:${view.orders.map((o, i) => `${o.code}=${counts[i]}`).join(' / ')}`,
  268. )
  269. check(
  270. 'A4-timeline-section-succeeded',
  271. view.placeholder === '' && view.orderPanelState === 'success',
  272. `工单面板 ${view.orderPanelState},轨迹无占位说明`,
  273. )
  274. }
  275. await page.close()
  276. }
  277. // ================================================================ B 七态路径 + 回退动作(本票核心)
  278. {
  279. const { page, errors, cards, view } = await openDeviceDetail('work-order-timeline')
  280. const rollbackOrder = view.orders.find((order) => order.code === 'WO-TL-ROLLBACK')
  281. const forwardOrder = view.orders.find((order) => order.code === 'WO-TL-FORWARD')
  282. const cancelledOrder = view.orders.find((order) => order.code === 'WO-TL-CANCELLED')
  283. if (!rollbackOrder) {
  284. check('B0-rollback-order-present', false, `没有找到 WO-TL-ROLLBACK:${view.orders.map((o) => o.code).join(',')}`)
  285. } else {
  286. // 验收第 1 条:七态完整展示 —— **七个节点一个不少**。
  287. check(
  288. 'B1-path-shows-all-seven-states',
  289. rollbackOrder.pathNodes.length === 7 &&
  290. rollbackOrder.pathNodes.map((node) => node.code).join('') === '1234567' &&
  291. rollbackOrder.pathNodes.map((node) => node.name).join(',') === '待派单,待接单,已接单,处理中,待验收,已结案,已取消',
  292. `七态节点:${rollbackOrder.pathNodes.map((node) => node.name).join('→')}`,
  293. )
  294. // 验收第 3 条:驳回**不**多出第八个节点。
  295. check(
  296. 'B2-reject-adds-no-eighth-node',
  297. rollbackOrder.pathNodes.length === 7 &&
  298. !rollbackOrder.pathNodes.some((node) => /驳回/.test(node.name)) &&
  299. !/驳回/.test(rollbackOrder.pathNodes.map((node) => node.name).join('')),
  300. '七态路径里没有「已驳回」这种第八个节点(驳回只出现在动作流水里)',
  301. )
  302. // 回退在界面上是一行带可见标记的记录。
  303. check(
  304. 'B3-rollback-is-a-visible-action-row',
  305. rollbackOrder.rollbackRows === 1 &&
  306. rollbackOrder.steps.some((step) => step.rollback && step.action === 'rollback') &&
  307. rollbackOrder.steps.some((step) => step.rollback && step.text.includes('回退')),
  308. `回退行:${(rollbackOrder.steps.find((step) => step.rollback) || {}).text}`,
  309. )
  310. check(
  311. 'B4-rollback-row-says-where-it-came-from',
  312. rollbackOrder.steps.some((step) => step.rollback && step.text.includes('待验收')) &&
  313. rollbackOrder.steps.some((step) => step.rollback && step.text.includes('验收驳回')),
  314. '回退那一行同时说得出「自待验收」与动作名「验收驳回」(不靠颜色也读得出)',
  315. )
  316. // 当前态与"走到过"分开:当前处理中,待验收标 reached 而不标 current。
  317. check(
  318. 'B5-current-and-reached-are-distinct',
  319. (rollbackOrder.pathNodes.find((node) => node.code === '4') || {}).state === 'current' &&
  320. (rollbackOrder.pathNodes.find((node) => node.code === '5') || {}).state === 'reached',
  321. `处理中=${(rollbackOrder.pathNodes.find((n) => n.code === '4') || {}).state},待验收=${(rollbackOrder.pathNodes.find((n) => n.code === '5') || {}).state}`,
  322. )
  323. // 对照:一路推进的那张零回退(证明 B3 不是恒真)。
  324. check(
  325. 'B6-forward-order-has-no-rollback',
  326. forwardOrder && forwardOrder.rollbackRows === 0 && forwardOrder.steps.every((step) => !step.rollback),
  327. `WO-TL-FORWARD 零回退(${forwardOrder ? forwardOrder.steps.length : 0} 步),因此 B3 的"恰一次回退"有信息量`,
  328. )
  329. // 已取消是路径外节点,不接在已结案后面冒充第八步。
  330. check(
  331. 'B7-cancelled-is-off-path',
  332. cancelledOrder &&
  333. (cancelledOrder.pathNodes.find((node) => node.code === '7') || {}).off === true &&
  334. (cancelledOrder.pathNodes.find((node) => node.code === '7') || {}).state === 'current' &&
  335. cancelledOrder.pathNodes.filter((node) => node.off).length === 1,
  336. cancelledOrder ? '已取消标 offPath 且是当前态;推进路径六个节点都不算走到过' : '没有 WO-TL-CANCELLED',
  337. )
  338. // 验收第 4 条:不提供任何写操作。
  339. const writeButtons = await page.evaluate(() =>
  340. Array.from(document.querySelectorAll('[data-point-detail] button')).map((el) => (el.textContent || '').trim()),
  341. )
  342. check(
  343. 'B8-no-work-order-write-actions',
  344. !writeButtons.some((label) => /派单|接单|处置|结案|取消工单|验收|提交/.test(label)),
  345. `详情里的按钮只有:${writeButtons.join(' / ') || '(无)'}`,
  346. )
  347. check('B9-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  348. }
  349. await page.close()
  350. }
  351. // ================================================================ C 轨迹分区失败 ≠ 没有动作记录
  352. {
  353. const { page, errors, cards, view } = await openDeviceDetail('work-order-timeline-failed')
  354. if (!cards.length) {
  355. check('C0-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  356. } else {
  357. check(
  358. 'C1-timeline-failure-says-not-fetched',
  359. view.placeholder.includes('加载失败') && !/没有动作记录/.test(view.placeholder),
  360. `轨迹分区失败时说「${view.placeholder}」`,
  361. )
  362. // "没取到" ≠ "没有":失败时**不许**出现「这张工单没有动作记录」。
  363. check(
  364. 'C2-failure-is-not-drawn-as-no-records',
  365. view.missingNotes.length === 0 && view.orders.every((order) => order.steps.length === 0 && order.rollbackRows === 0),
  366. `**没有**把它画成「这张工单没有动作记录」(该措辞出现 ${view.missingNotes.length} 次,动作流水 0 条)`,
  367. )
  368. // 反面:轨迹没拿到,"这张工单现在在哪一态"仍要看得出来(验收第 1 条不因轨迹失败而丢)。
  369. check(
  370. 'C2b-path-survives-without-tracks',
  371. view.orders.length >= 1 && view.orders.every((order) => order.pathNodes.length === 7),
  372. `轨迹没拿到时七态路径仍在:${view.orders.map((o) => `${o.code}=${o.pathNodes.length}节点`).join(' / ')}`,
  373. )
  374. // 分区独立:工单列表照常成功,只有轨迹分区坏掉(票 11 的立场在本票的延续)。
  375. check(
  376. 'C3-order-list-still-renders-alongside',
  377. view.orderPanelState === 'success' && view.orders.length >= 1,
  378. `工单面板照常 ${view.orderPanelState}(${view.orders.length} 张工单仍在),只有轨迹没取到`,
  379. )
  380. check('C4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  381. }
  382. await page.close()
  383. }
  384. // ================================================================ D 泄漏回归(票 05/11 的同一条)
  385. {
  386. const { page, errors, cards, view } = await openDeviceDetail('work-order-timeline')
  387. if (!cards.length) {
  388. check('D0-scenario-has-device-card', false, '点位摘要里没有设备卡片')
  389. } else {
  390. const codeLeaks = REASON_CODES.filter((code) => new RegExp(`\\b${code}\\b`).test(view.cardText))
  391. // 字段路径(`archive.deviceName` 这类)也属接口词汇;时间线里不该冒出 `timelineEntries` 之类。
  392. const pathLeaks = (view.cardText.match(/\b[a-zA-Z]+\.[a-zA-Z]+\b/g) || []).filter((token) => !token.includes('.webp') && !token.includes('.js'))
  393. check(
  394. 'D1-visible-text-has-no-internal-codes',
  395. codeLeaks.length === 0 && pathLeaks.length === 0,
  396. codeLeaks.length || pathLeaks.length
  397. ? `可见文案泄漏:代号 ${codeLeaks.join(',') || '无'} · 字段路径 ${pathLeaks.join(',') || '无'}`
  398. : '整卡可见文本零内部代号、零字段路径',
  399. )
  400. // 打开详情不重建地图(票 10 的常驻语义不回退)。
  401. check(
  402. 'D2-detail-does-not-rebuild-map',
  403. view.mapHosts === 1 && view.dsPlan >= 0,
  404. `地图宿主 ${view.mapHosts} 个(未重建)`,
  405. )
  406. check('D3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  407. }
  408. await page.close()
  409. }
  410. } finally {
  411. await browser.close()
  412. }
  413. const failed = results.filter((item) => !item.pass)
  414. console.log(`\nT12 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
  415. process.exit(failed.length ? 1 : 0)