/** * T33 真机走查:一张图浮层布局(维护者 2026-09-18 的三条要求)。 * * ① 点位状态图例放到**一张图最上方**(原来在底部,位置效果不好); * ② 地图摘要**最小化形态**(去掉「地图摘要」文字标题,收成状态点 + 短文案 + 动作的小胶囊); * ③ 浮层之间**不能互相叠字** —— 顺带修掉票 32 引入的「生产形态下引擎状态与底部摘要行重叠」。 * * 断言方式是**真实几何**(getBoundingClientRect)而不是"看截图":图例是否贴顶、三块浮层是否相交、 * 摘要块宽度是否收窄、动作按钮与选择器是否还在(票 07/08/09/10 的走查依赖它们)。 * * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t33-overlay-layout-walkthrough.mjs */ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000' const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit` const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js' const OUT = join(tmpdir(), 'cockpit-t33-layout.png') const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8')) const results = [] function check(id, desc, pass, evidence) { results.push({ id, desc, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) /** 两个矩形是否相交(留 0 容差:边缘相接不算叠)。 */ function overlaps(a, b) { if (!a || !b) return false 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 } const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t33-profile'), defaultViewport: { width: 1680, height: 950 }, args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'], }) console.log(`Chrome: ${await browser.version()}`) async function openWith(uiDebug, shot) { const page = await browser.newPage() const errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) await page.evaluateOnNewDocument( (cfg, d) => { window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false }, ui: { debug: d } } } }, config, uiDebug, ) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page.waitForFunction( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready', { timeout: 60000 }, ) await sleep(2500) if (shot) await page.screenshot({ path: shot }) const m = await page.evaluate(() => { const r = (sel) => { const el = document.querySelector(sel) if (!el) return null const b = el.getBoundingClientRect() return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) } } const panel = document.querySelector('[data-map-blocked-panel]') const list = document.querySelector('[data-map-blocked-list]') const readText = (sel) => { const el = panel ? panel.querySelector(sel) : null return el ? el.textContent.trim() : '' } return { map: r('.cockpit-shell__map .map-canvas'), rightCol: r('.cockpit-shell__column.is-right'), legend: r('[data-map-legend]'), statusLine: r('.map-holo__status'), pill: r('[data-map-engine-status]'), headline: r('.map-holo__headline'), summary: r('.cockpit-shell__map-summary'), summaryDot: r('.cockpit-shell__map-summary-dot'), blockedPanel: r('[data-map-blocked-panel]'), blockedCollapsed: panel ? panel.classList.contains('is-compact') : null, blockedListPresent: !!list, // 票 07 的断言面:收起形态下这两个文本也必须仍在(DOM 常驻,只是视觉收起) backendText: readText('[data-map-coordinate-backend]'), loadedText: readText('[data-map-coordinate-loaded]'), hasSummaryLabelText: !!Array.from(document.querySelectorAll('.cockpit-shell__scope-right .cockpit-shell__scope-label')) .find((el) => el.textContent.trim() === '地图摘要'), summaryAction: !!document.querySelector('[data-map-summary-action]'), summaryLabel: !!document.querySelector('[data-map-summary-label]'), legendTags: document.querySelectorAll('[data-map-legend] .status-tag').length, uiMode: (document.querySelector('[data-map-engine-status]') || {}).dataset?.uiMode || '', } }) return { page, m, errors } } try { for (const uiDebug of [false, true]) { const tag = uiDebug ? 'debug' : 'prod' const { page, m, errors } = await openWith(uiDebug, uiDebug ? null : OUT) // ① 图例贴顶 const legendTopOffset = m.legend && m.map ? m.legend.y - m.map.y : null check( `A1-${tag}-legend-at-top`, '点位状态图例在**一张图最上方**(距画布顶 ≤ 24px),且图例本身仍在(颜色语义)', legendTopOffset !== null && legendTopOffset >= 0 && legendTopOffset <= 24 && m.legendTags > 0, JSON.stringify({ legendTopOffset, tags: m.legendTags, mode: m.uiMode }), ) // ③ 浮层不叠字 const legendVsStatus = overlaps(m.legend, m.statusLine) const pillVsStatus = overlaps(m.pill, m.statusLine) const pillVsLegend = overlaps(m.pill, m.legend) const pillVsHeadline = overlaps(m.pill, m.headline) check( `A2-${tag}-no-overlap`, '图例 / 引擎状态 / 底部摘要行三者互不重叠(票 32 引入的叠字已修)', !legendVsStatus && !pillVsStatus && !pillVsLegend && !pillVsHeadline, JSON.stringify({ legendVsStatus, pillVsStatus, pillVsLegend, pillVsHeadline, legend: m.legend, pill: m.pill, statusLine: m.statusLine }), ) // ② 摘要最小化 check( `A3-${tag}-summary-minimized`, '地图摘要收成小胶囊:去掉「地图摘要」文字标题、带状态点、宽度收窄,但动作按钮与状态文案选择器仍在', m.hasSummaryLabelText === false && !!m.summaryDot && m.summary && m.summary.w <= 300 && m.summaryAction && m.summaryLabel, JSON.stringify({ labelText: m.hasSummaryLabelText, dot: !!m.summaryDot, w: m.summary ? m.summary.w : null, action: m.summaryAction, label: m.summaryLabel }), ) // 票 35 续:两块摘要都贴一张图**右下角**(让开右栏),且竖排不叠 const rightCol = m.rightCol const gapOf = (r) => (r && rightCol ? Math.round(rightCol.x - r.x - r.w) : null) const blockedGap = gapOf(m.blockedPanel) const pillGap = gapOf(m.pill) // 胶囊在**下半屏**、且整体在状态日志之上(不叠) const blockedLow = m.map && m.blockedPanel ? m.blockedPanel.y > m.map.y + m.map.h * 0.5 : false const stackedAbovePill = m.blockedPanel && m.pill ? m.blockedPanel.y + m.blockedPanel.h <= m.pill.y : false // 生产形态:状态日志也贴右下角、并在胶囊下方;调试形态:药丸是顶部诊断读数(t32 A7 另测),不参与右下角。 const pillOk = uiDebug ? true : pillGap !== null && pillGap >= 4 && pillGap <= 60 check( `A7-${tag}-bottom-right-stack`, '「无地图位置」胶囊贴**右下角**(让开右栏、在下半屏),生产形态下引擎状态日志同为右下角且在胶囊下方、互不叠', blockedGap !== null && blockedGap >= 4 && blockedGap <= 60 && blockedLow && pillOk && !overlaps(m.blockedPanel, m.pill) && (uiDebug || stackedAbovePill), JSON.stringify({ blockedGap, pillGap, blockedLow, stackedAbovePill, blocked: m.blockedPanel, pill: m.pill, rightColX: rightCol ? rightCol.x : null }), ) check(`A4-${tag}-no-errors`, `${tag} 形态无页面级报错`, errors.length === 0, JSON.stringify(errors.slice(0, 2))) // 票 35:无地图位置浮块必须是「收起的一行小胶囊」,不能再占中央视野 check( `A5-${tag}-blocked-panel-compact`, '「无地图位置」默认收起成一行小胶囊(高度 ≤ 32px、宽度 ≤ 300px),且两条口径文本仍在 DOM 里(票 07 断言面不变)', m.blockedCollapsed === true && m.blockedPanel && m.blockedPanel.h <= 32 && m.blockedPanel.w <= 300 && m.backendText.length > 0 && m.loadedText.length > 0, JSON.stringify({ collapsed: m.blockedCollapsed, rect: m.blockedPanel, backend: m.backendText.slice(0, 24), loaded: m.loadedText.slice(0, 24) }), ) // 展开后仍应比旧版窄(宽度上限 280,不再是 340) await page.click('[data-map-blocked-toggle]') await sleep(500) const expanded = await page.evaluate(() => { const el = document.querySelector('[data-map-blocked-panel]') const b = el.getBoundingClientRect() return { w: Math.round(b.width), h: Math.round(b.height), list: !!document.querySelector('[data-map-blocked-list]') } }) check( `A6-${tag}-blocked-panel-expanded-narrow`, '展开后仍比旧版收窄(宽 ≤ 300px)且清单出现', expanded.w <= 300 && expanded.list === true, JSON.stringify(expanded), ) await page.click('[data-map-blocked-toggle]') await page.close() } } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T33 走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T33 走查全部通过。')