| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- /**
- * 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)
|