/** * T09 真机走查:点位状态标记与图例在浏览器里确实成立(票 09)。 * * 逻辑脚本(t09-point-status.mjs)已经把状态词表、标记计划与「图例=标记」判据测透了; * 这里只补**只有在真机才成立**的部分: * 1. 标记真的作为 Cesium billboard 画在地图上(引擎实体存在、有图片、形状不同); * 2. 每种状态的颜色确实不同 —— 颜色是服务层从 token 的**计算样式**读出来再烘焙进 SVG 的, * 纯逻辑断言证明不了 CSS 变量真的取到了值(取不到会全体回退兜底色,颜色就都一样了); * 3. 地图图例与地图上的标记逐行一致(同一个 DOM 与同一批实体对照,不是两份 JS 数据对比); * 4. 图例不只靠颜色:每行有形状(真 SVG 片段)+ 文字。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务): * node .scratch/cockpit-shell-panel-layout/verify/t09-point-status-walkthrough.mjs * * 演示开关(宿主全局键注入,不改代码重新构建): * `window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'` —— 后端六态 + 未关联设备 + 一个前端不认识的状态 * 人工演示同一件事:控制台里 `__cockpitMap.getPointMarkers()` 看计划与图例、 * `__cockpitMap.mapSummary.advance()` 一页页推进。 */ 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 results = [] /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */ function check(id, pass, evidence) { results.push({ id, pass }) const rest = evidence === undefined ? '' : String(evidence) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t09-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()}`) /** * 一次读全:宿主 dataset(走查口径)+ 地图上的真实实体(`__cockpitMap.getPointMarkers()`) * + 图例 DOM(形状/色键/文字)。 * * 关键:**实体这一侧读的是 `readPointMarkers(viewer)` 从 Cesium 实体上取的值**, * 图例那一侧读的是真实 DOM。两者对照才叫「图例与标记一致」; * 若两边都读同一个 JS 对象,那条断言就恒真(自己跟自己比)。 */ const readMarkers = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const legendRows = Array.from(document.querySelectorAll('[data-map-legend] .status-tag')) const dev = window.__cockpitMap && window.__cockpitMap.getPointMarkers ? window.__cockpitMap.getPointMarkers() : null // 语义色的实际取法(与 mapService.toneColors 同一条路):从外壳的计算样式读。 const shell = document.querySelector('.cockpit-shell') const styles = shell ? getComputedStyle(shell) : null const tone = (name) => (styles ? styles.getPropertyValue(`--${name}`).trim() : '') return { engine: (host && host.dataset.mapEngine) || '', phase: (host && host.dataset.mapSummary) || '', loaded: Number((host && host.dataset.mapSummaryLoaded) || 0), // dataset 口径(服务层写的) dsMarkers: Number((host && host.dataset.mapMarkers) || 0), dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0), dsStatus: (host && host.dataset.mapMarkersStatus) || '', dsShapes: (host && host.dataset.mapMarkerShapes) || '', // 真实实体口径(引擎里真有什么) entities: dev ? dev.onMap : -1, entityByStatus: dev ? dev.entityByStatus : {}, entityShapes: dev ? dev.shapes : [], // 计划口径(图例的计数来自这里,与实体数必须相等) planLegend: dev ? dev.legend.filter((row) => row.count > 0).map((row) => `${row.key}:${row.count}`) : [], // 图例 DOM legend: legendRows.map((el) => ({ key: el.dataset.legendKey || '', tone: el.dataset.tone || '', shape: el.dataset.shape || '', label: (el.querySelector('.status-tag__label') || {}).textContent?.trim() || '', hasShapeSvg: !!el.querySelector('.status-tag__shape'), shapeMarkup: (el.querySelector('.status-tag__shape') || {}).innerHTML || '', color: getComputedStyle(el.querySelector('.status-tag__shape') || el).color, })), legendCount: legendRows.length, legendHosts: document.querySelectorAll('[data-map-legend]').length, // 事件四级图例不该出现在地图上(票 09:不把事件等级色混用为点位状态色) eventLevelTags: Array.from(document.querySelectorAll('[data-map-legend] .status-tag')).filter((el) => /^[1-4]级$/.test(((el.querySelector('.status-tag__label') || {}).textContent || '').trim()), ).length, // token 的实际值(证明颜色不是兜底常量) tokens: { lv1: tone('lv1'), lv2: tone('lv2'), lv3: tone('lv3'), lv4: tone('lv4'), neutral: tone('neutral') }, // 每行的实际颜色算出来的 CSS 值,供「颜色各不相同」判定 markerImages: dev && dev.markerImages ? dev.markerImages : {}, engineStatusPill: (document.querySelector('[data-map-engine-status]') || {}).textContent || '', mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, } }) /** 打开页面并把地图摘要翻到底(mock 的状态样例:7 点 + 1 设备 = 8 个对象)。 */ async function openPage(inject) { const page = await browser.newPage() const errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) }) await page.evaluateOnNewDocument(() => { // 引导层会盖住页面,走查不需要它(票 30 的开关)。 window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } } }) if (inject) await page.evaluateOnNewDocument(inject) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) // 引擎要拉静态资源/地形,比取数慢:等它落到终态,否则「标记没画上」会被误判成失败。 await page .waitForFunction( () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 40000 }, ) .catch(() => {}) await sleep(600) return { page, errors } } /** 点「加载下一页」直到翻完(mock 按页大小 2 分 4 页)。 */ async function advanceUntilSettled(page, maxSteps = 12) { for (let step = 0; step < maxSteps; step += 1) { const state = await readMarkers(page) if (['complete', 'empty', 'failed', 'expired'].includes(state.phase)) break const clicked = await page.evaluate(() => { const button = document.querySelector('[data-map-summary-action]') if (!button || button.disabled) return false button.click() return true }) if (!clicked) break const before = state.loaded await page .waitForFunction( (previous) => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0) const phase = (host && host.dataset.mapSummary) || '' return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase) }, { timeout: 10000 }, before, ) .catch(() => {}) await sleep(350) } return readMarkers(page) } try { // ---------- A 标记真的画在地图上,且按后端状态渲染 ---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses' window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const settled = await advanceUntilSettled(page) check( 'A1-engine-ready', ['ready', 'disabled'].includes(settled.engine), `engine=${settled.engine} · 浮标「${settled.engineStatusPill.slice(0, 60)}」`, ) // 6 个状态点位 + 1 个未知状态点位 + 1 个未关联设备 = 8 个可上图对象。 check( 'A2-markers-rendered-as-entities', settled.entities === 8 && settled.dsMarkers === 8 && settled.dsPlan === 8, `真实实体 ${settled.entities} · dataset ${settled.dsMarkers} · 计划 ${settled.dsPlan}(加载 ${settled.loaded} 个)`, ) check( 'A3-backend-six-statuses-present', ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].every((key) => settled.entityByStatus[key] === 1), JSON.stringify(settled.entityByStatus), ) check( 'A4-unknown-status-not-guessed', settled.entityByStatus.UNKNOWN === 1, `后端新增的 MAINTENANCE 落「状态未发布」而不是任何真实状态:${JSON.stringify(settled.entityByStatus)}`, ) check( 'A5-unassociated-separate-row', settled.entityByStatus.UNASSOCIATED === 1, '未关联设备自成一行(不借用在线态)', ) check('A6-single-map-host', settled.mapHosts === 1, `地图宿主 ${settled.mapHosts} 个`) check('A7-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) // ---------- B 形状:不只靠颜色 ---------- const sixShapes = ['triangle', 'diamond', 'square', 'circle', 'ring', 'hollowSquare'] check( 'B1-eight-distinct-shapes-on-map', sixShapes.every((shape) => settled.entityShapes.includes(shape)) && settled.entityShapes.includes('hollowDiamond') && settled.entityShapes.includes('cross') && settled.entityShapes.length === 8, `地图上 ${settled.entityShapes.length} 种形状:${settled.entityShapes.join(', ')}`, ) check( 'B2-dataset-exposes-shapes', settled.dsShapes.split(',').filter(Boolean).length === 8, `dataset shapes = ${settled.dsShapes}`, ) // ---------- C 颜色真的从 token 读出来、且各状态不同 ---------- // // 注意这里**必须把颜色单独抠出来比**,不能只比整张图片是否不同: // 图片里还含形状,形状本来就各不相同 —— 那样写的话即使所有状态用同一个颜色 // (比如 token 没读到、全体回退兜底色),断言照样绿。第一版正是这么写的, // 对抗式复验(把 toneColors 改成恒返回一个色)抓到它假绿 25/25。 const imageOf = (key) => (settled.markerImages[key] || {}).image || '' const colorOf = (key) => { const image = imageOf(key) if (!image) return '' const decoded = decodeURIComponent(image) const match = decoded.match(/color="([^"]+)"/) return match ? match[1] : '' } check( 'C1-tokens-resolved-from-css', Object.values(settled.tokens).every((value) => /^#|rgb/.test(value)), JSON.stringify(settled.tokens), ) const colorsByStatus = { ALARM: colorOf('ALARM'), WARNING: colorOf('WARNING'), WORK_ORDER: colorOf('WORK_ORDER'), NORMAL: colorOf('NORMAL'), OFFLINE: colorOf('OFFLINE'), } const distinctColors = new Set(Object.values(colorsByStatus).filter(Boolean)) check( 'C2-marker-colors-differ-per-status', distinctColors.size === 5 && Object.values(colorsByStatus).every(Boolean), `5 个状态 → ${distinctColors.size} 种颜色:${Object.entries(colorsByStatus).map(([k, v]) => `${k}=${v}`).join(' ')}`, ) // 颜色必须**等于 token 的实测值**(而不只是"彼此不同"):证明是读出来的,不是常量。 const colorMismatch = Object.entries(colorsByStatus).filter(([key, color]) => { const toneKey = { ALARM: 'lv1', WARNING: 'lv2', WORK_ORDER: 'lv3', NORMAL: 'lv4', OFFLINE: 'neutral' }[key] return color.toLowerCase() !== settled.tokens[toneKey].toLowerCase() }) check( 'C3-marker-color-equals-token', colorMismatch.length === 0 && /^data:image\/svg\+xml/.test(imageOf('ALARM')), colorMismatch.length ? colorMismatch.map(([k, v]) => `${k} 图片是 ${v}`).join(' | ') : `五个状态的标记颜色逐个等于 .cockpit-shell 上 --lv1/--lv2/--lv3/--lv4/--neutral 的实测值`, ) // ---------- D 图例与标记一致(DOM 与真实实体对照)---------- check('D1-legend-rendered-once', settled.legendHosts === 1, `图例宿主 ${settled.legendHosts} 个`) check('D2-legend-lists-eight-rows', settled.legendCount === 8, `图例 ${settled.legendCount} 行(把八种含义都列出来)`) const legendByKey = new Map(settled.legend.map((row) => [row.key, row])) const entityMismatch = Object.entries(settled.entityByStatus).filter(([key, count]) => { const row = legendByKey.get(key) if (!row) return true // 图例文字形如「报警 1」;解析出计数与实体数对照。 const shown = Number((row.label.match(/(\d+)\s*$/) || [])[1]) return shown !== count }) check( 'D3-legend-counts-equal-entities', entityMismatch.length === 0, entityMismatch.length ? JSON.stringify(entityMismatch) : `图例计数与地图实体逐个相等:${settled.planLegend.join(' · ')}`, ) // 形状与色键必须逐行对上(图例那一行画出来的形状,与地图上该状态的标记形状同名)。 const shapeMismatch = settled.legend .filter((row) => { const image = settled.markerImages[row.key] return image && image.shape !== row.shape }) .map((row) => `${row.key}: 图例 ${row.shape} vs 标记 ${settled.markerImages[row.key].shape}`) check('D4-legend-shape-matches-marker', shapeMismatch.length === 0, shapeMismatch.length ? shapeMismatch.join(' | ') : '八行形状逐个对上地图标记') const toneMismatch = settled.legend .filter((row) => { const image = settled.markerImages[row.key] return image && image.tone !== row.tone }) .map((row) => `${row.key}: 图例 ${row.tone} vs 标记 ${settled.markerImages[row.key].tone}`) check('D5-legend-tone-matches-marker', toneMismatch.length === 0, toneMismatch.length ? toneMismatch.join(' | ') : '八行色键逐个对上地图标记') // 每行必须同时有形状 SVG 与文字(验收第 3 条)。 const lacking = settled.legend.filter((row) => !row.hasShapeSvg || !row.label) check( 'D6-every-legend-row-has-shape-and-text', lacking.length === 0, lacking.length ? lacking.map((row) => row.key).join(', ') : settled.legend.map((row) => `${row.label}[${row.shape}]`).join(' '), ) // 形状是真的画出来的(内联 SVG 片段非空),不是一个空元素占位。 const emptyShape = settled.legend.filter((row) => row.shapeMarkup.trim().length < 10) check('D7-shape-markup-not-empty', emptyShape.length === 0, emptyShape.length ? emptyShape.map((r) => r.key).join(', ') : '每行形状都有真实 SVG 片段') // 图例那一行的实际渲染颜色必须各不相同(颜色不是唯一线索,但仍要是对的)。 const legendColors = new Set(settled.legend.filter((row) => row.tone !== 'neutral').map((row) => row.color)) check( 'D8-legend-colors-distinct', legendColors.size === 4, `四个语义色在页面上渲染成 ${legendColors.size} 种颜色:${[...legendColors].join(', ')}`, ) // ---------- E 不与事件等级混用 ---------- check( 'E1-no-event-level-legend-on-map', settled.eventLevelTags === 0, '地图图例里没有「1级..4级」事件等级行(票 09:不把事件等级色混用为点位状态色)', ) console.log(`\n图例快照:${settled.legend.map((row) => `${row.label}(${row.tone}/${row.shape})`).join(' | ')}`) console.log(`状态分布:${settled.dsStatus}`) await page.close() } // ---------- F 默认样例不受影响(票 07/08 的证据不能被本票改动)---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const settled = await advanceUntilSettled(page) // 默认样例是 3 点 + 2 设备 = 5 个对象、其中 2 个可上图(票 07/08 原样)。 check( 'F1-default-sample-unchanged', settled.dsPlan === 2 && settled.entities === 2 && settled.loaded === 5, `默认样例仍为 计划 ${settled.dsPlan} · 实体 ${settled.entities} · 已加载 ${settled.loaded}(票 07/08 的数)`, ) check('F2-default-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- G 空快照:图例在位、标记为 0(不是「假绿」而是真的没有)---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'empty' window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot' }) await sleep(900) const state = await readMarkers(page) check( 'G1-empty-snapshot-zero-markers', state.dsMarkers === 0 && state.entities === 0 && state.legendCount === 8, `标记 ${state.dsMarkers} · 实体 ${state.entities} · 图例仍 ${state.legendCount} 行(形状含义可查)`, ) check('G2-empty-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- H 首屏自动补画:不点任何按钮,标记也必须出现在地图上 ---------- // // 这条是被**视觉探针**(截图里图例有计数、状态行却写「点位标记 0」)逼出来的: // 引擎冷启动比装载器慢得多,第一版的重试封顶 10 秒,超时后第一批点位就再也没画上, // 只有用户去点「加载下一页」才会因对象变化重新触发。走查当时没抓到 —— 因为它一路在点分页。 // 所以这里**一次都不点**:只等引擎 ready,然后要求标记自己出现。 { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses' window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) // 只等"标记数 > 0",不点任何按钮;等不到就是没补画。 const painted = await page .waitForFunction( () => { const host = document.querySelector('.cockpit-shell__map .map-canvas') return Number((host && host.dataset.mapMarkers) || 0) > 0 }, { timeout: 30000 }, ) .then(() => true) .catch(() => false) const state = await readMarkers(page) check( 'H1-first-batch-auto-painted', painted && state.entities > 0 && state.dsMarkers === state.dsPlan, `未点任何按钮:实体 ${state.entities} · dataset ${state.dsMarkers} · 计划 ${state.dsPlan}(引擎 ${state.engine})`, ) check( 'H2-first-batch-legend-consistent', state.legendCount === 8 && Object.entries(state.entityByStatus).every(([key, count]) => { const row = state.legend.find((item) => item.key === key) return row && Number((row.label.match(/(\d+)\s*$/) || [])[1]) === count }), `首屏图例 ${state.legendCount} 行,与实体 ${JSON.stringify(state.entityByStatus)} 逐个对上`, ) check('H3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } } finally { await browser.close() } const failed = results.filter((item) => !item.pass) console.log(`\nT09 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`) process.exit(failed.length ? 1 : 0)