t33-overlay-layout-walkthrough.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * T33 真机走查:一张图浮层布局(维护者 2026-09-18 的三条要求)。
  3. *
  4. * ① 点位状态图例放到**一张图最上方**(原来在底部,位置效果不好);
  5. * ② 地图摘要**最小化形态**(去掉「地图摘要」文字标题,收成状态点 + 短文案 + 动作的小胶囊);
  6. * ③ 浮层之间**不能互相叠字** —— 顺带修掉票 32 引入的「生产形态下引擎状态与底部摘要行重叠」。
  7. *
  8. * 断言方式是**真实几何**(getBoundingClientRect)而不是"看截图":图例是否贴顶、三块浮层是否相交、
  9. * 摘要块宽度是否收窄、动作按钮与选择器是否还在(票 07/08/09/10 的走查依赖它们)。
  10. *
  11. * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t33-overlay-layout-walkthrough.mjs
  12. */
  13. import { readFileSync } from 'node:fs'
  14. import { join } from 'node:path'
  15. import { tmpdir } from 'node:os'
  16. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  17. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  18. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  19. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  20. const OUT = join(tmpdir(), 'cockpit-t33-layout.png')
  21. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  22. const results = []
  23. function check(id, desc, pass, evidence) {
  24. results.push({ id, desc, pass })
  25. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  26. }
  27. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  28. /** 两个矩形是否相交(留 0 容差:边缘相接不算叠)。 */
  29. function overlaps(a, b) {
  30. if (!a || !b) return false
  31. return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
  32. }
  33. const puppeteer = (await import(PUPPETEER)).default
  34. const browser = await puppeteer.launch({
  35. executablePath: CHROME,
  36. headless: true,
  37. userDataDir: join(tmpdir(), 'cockpit-t33-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. async function openWith(uiDebug, shot) {
  43. const page = await browser.newPage()
  44. const errors = []
  45. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  46. await page.evaluateOnNewDocument(
  47. (cfg, d) => {
  48. window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false }, ui: { debug: d } } }
  49. },
  50. config,
  51. uiDebug,
  52. )
  53. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  54. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  55. await page.waitForFunction(
  56. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  57. { timeout: 60000 },
  58. )
  59. await sleep(2500)
  60. if (shot) await page.screenshot({ path: shot })
  61. const m = await page.evaluate(() => {
  62. const r = (sel) => {
  63. const el = document.querySelector(sel)
  64. if (!el) return null
  65. const b = el.getBoundingClientRect()
  66. return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }
  67. }
  68. const panel = document.querySelector('[data-map-blocked-panel]')
  69. const list = document.querySelector('[data-map-blocked-list]')
  70. const readText = (sel) => {
  71. const el = panel ? panel.querySelector(sel) : null
  72. return el ? el.textContent.trim() : ''
  73. }
  74. return {
  75. map: r('.cockpit-shell__map .map-canvas'),
  76. rightCol: r('.cockpit-shell__column.is-right'),
  77. legend: r('[data-map-legend]'),
  78. statusLine: r('.map-holo__status'),
  79. pill: r('[data-map-engine-status]'),
  80. headline: r('.map-holo__headline'),
  81. summary: r('.cockpit-shell__map-summary'),
  82. summaryDot: r('.cockpit-shell__map-summary-dot'),
  83. blockedPanel: r('[data-map-blocked-panel]'),
  84. blockedCollapsed: panel ? panel.classList.contains('is-compact') : null,
  85. blockedListPresent: !!list,
  86. // 票 07 的断言面:收起形态下这两个文本也必须仍在(DOM 常驻,只是视觉收起)
  87. backendText: readText('[data-map-coordinate-backend]'),
  88. loadedText: readText('[data-map-coordinate-loaded]'),
  89. hasSummaryLabelText: !!Array.from(document.querySelectorAll('.cockpit-shell__scope-right .cockpit-shell__scope-label'))
  90. .find((el) => el.textContent.trim() === '地图摘要'),
  91. summaryAction: !!document.querySelector('[data-map-summary-action]'),
  92. summaryLabel: !!document.querySelector('[data-map-summary-label]'),
  93. legendTags: document.querySelectorAll('[data-map-legend] .status-tag').length,
  94. uiMode: (document.querySelector('[data-map-engine-status]') || {}).dataset?.uiMode || '',
  95. }
  96. })
  97. return { page, m, errors }
  98. }
  99. try {
  100. for (const uiDebug of [false, true]) {
  101. const tag = uiDebug ? 'debug' : 'prod'
  102. const { page, m, errors } = await openWith(uiDebug, uiDebug ? null : OUT)
  103. // ① 图例贴顶
  104. const legendTopOffset = m.legend && m.map ? m.legend.y - m.map.y : null
  105. check(
  106. `A1-${tag}-legend-at-top`,
  107. '点位状态图例在**一张图最上方**(距画布顶 ≤ 24px),且图例本身仍在(颜色语义)',
  108. legendTopOffset !== null && legendTopOffset >= 0 && legendTopOffset <= 24 && m.legendTags > 0,
  109. JSON.stringify({ legendTopOffset, tags: m.legendTags, mode: m.uiMode }),
  110. )
  111. // ③ 浮层不叠字
  112. const legendVsStatus = overlaps(m.legend, m.statusLine)
  113. const pillVsStatus = overlaps(m.pill, m.statusLine)
  114. const pillVsLegend = overlaps(m.pill, m.legend)
  115. const pillVsHeadline = overlaps(m.pill, m.headline)
  116. check(
  117. `A2-${tag}-no-overlap`,
  118. '图例 / 引擎状态 / 底部摘要行三者互不重叠(票 32 引入的叠字已修)',
  119. !legendVsStatus && !pillVsStatus && !pillVsLegend && !pillVsHeadline,
  120. JSON.stringify({ legendVsStatus, pillVsStatus, pillVsLegend, pillVsHeadline, legend: m.legend, pill: m.pill, statusLine: m.statusLine }),
  121. )
  122. // ② 摘要最小化
  123. check(
  124. `A3-${tag}-summary-minimized`,
  125. '地图摘要收成小胶囊:去掉「地图摘要」文字标题、带状态点、宽度收窄,但动作按钮与状态文案选择器仍在',
  126. m.hasSummaryLabelText === false &&
  127. !!m.summaryDot &&
  128. m.summary &&
  129. m.summary.w <= 300 &&
  130. m.summaryAction &&
  131. m.summaryLabel,
  132. JSON.stringify({ labelText: m.hasSummaryLabelText, dot: !!m.summaryDot, w: m.summary ? m.summary.w : null, action: m.summaryAction, label: m.summaryLabel }),
  133. )
  134. // 票 35 续:两块摘要都贴一张图**右下角**(让开右栏),且竖排不叠
  135. const rightCol = m.rightCol
  136. const gapOf = (r) => (r && rightCol ? Math.round(rightCol.x - r.x - r.w) : null)
  137. const blockedGap = gapOf(m.blockedPanel)
  138. const pillGap = gapOf(m.pill)
  139. // 胶囊在**下半屏**、且整体在状态日志之上(不叠)
  140. const blockedLow = m.map && m.blockedPanel ? m.blockedPanel.y > m.map.y + m.map.h * 0.5 : false
  141. const stackedAbovePill = m.blockedPanel && m.pill ? m.blockedPanel.y + m.blockedPanel.h <= m.pill.y : false
  142. // 生产形态:状态日志也贴右下角、并在胶囊下方;调试形态:药丸是顶部诊断读数(t32 A7 另测),不参与右下角。
  143. const pillOk = uiDebug ? true : pillGap !== null && pillGap >= 4 && pillGap <= 60
  144. check(
  145. `A7-${tag}-bottom-right-stack`,
  146. '「无地图位置」胶囊贴**右下角**(让开右栏、在下半屏),生产形态下引擎状态日志同为右下角且在胶囊下方、互不叠',
  147. blockedGap !== null && blockedGap >= 4 && blockedGap <= 60 && blockedLow && pillOk && !overlaps(m.blockedPanel, m.pill) && (uiDebug || stackedAbovePill),
  148. JSON.stringify({ blockedGap, pillGap, blockedLow, stackedAbovePill, blocked: m.blockedPanel, pill: m.pill, rightColX: rightCol ? rightCol.x : null }),
  149. )
  150. check(`A4-${tag}-no-errors`, `${tag} 形态无页面级报错`, errors.length === 0, JSON.stringify(errors.slice(0, 2)))
  151. // 票 35:无地图位置浮块必须是「收起的一行小胶囊」,不能再占中央视野
  152. check(
  153. `A5-${tag}-blocked-panel-compact`,
  154. '「无地图位置」默认收起成一行小胶囊(高度 ≤ 32px、宽度 ≤ 300px),且两条口径文本仍在 DOM 里(票 07 断言面不变)',
  155. m.blockedCollapsed === true &&
  156. m.blockedPanel &&
  157. m.blockedPanel.h <= 32 &&
  158. m.blockedPanel.w <= 300 &&
  159. m.backendText.length > 0 &&
  160. m.loadedText.length > 0,
  161. JSON.stringify({ collapsed: m.blockedCollapsed, rect: m.blockedPanel, backend: m.backendText.slice(0, 24), loaded: m.loadedText.slice(0, 24) }),
  162. )
  163. // 展开后仍应比旧版窄(宽度上限 280,不再是 340)
  164. await page.click('[data-map-blocked-toggle]')
  165. await sleep(500)
  166. const expanded = await page.evaluate(() => {
  167. const el = document.querySelector('[data-map-blocked-panel]')
  168. const b = el.getBoundingClientRect()
  169. return { w: Math.round(b.width), h: Math.round(b.height), list: !!document.querySelector('[data-map-blocked-list]') }
  170. })
  171. check(
  172. `A6-${tag}-blocked-panel-expanded-narrow`,
  173. '展开后仍比旧版收窄(宽 ≤ 300px)且清单出现',
  174. expanded.w <= 300 && expanded.list === true,
  175. JSON.stringify(expanded),
  176. )
  177. await page.click('[data-map-blocked-toggle]')
  178. await page.close()
  179. }
  180. } finally {
  181. await browser.close()
  182. }
  183. const failed = results.filter((r) => !r.pass)
  184. console.log('')
  185. console.log(`T33 走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`)
  186. if (failed.length) {
  187. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  188. process.exit(1)
  189. }
  190. console.log('T33 走查全部通过。')