| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- /**
- * T44 真机走查:设备位置符号与可替换资产接缝(票 44 验收第 8 条的**外壳走查**那一半)。
- *
- * 逻辑闸门(`t44-device-assets.mjs`,44/44 + 变异 21/21)已把资产接缝、降级分类、
- * 尺寸层级、独立状态词表与长文本边界测透了,并且有 G 段在 Node 里用 pinia 驱动
- * `deviceLayerStore.syncForSelection()` 这条真实数据路径。这里只补**只有在真机才成立**的部分:
- * 1. 走真实用户路径选中监测点 → 设备清单**真的**渲染出来(不是 store 里的行,是 DOM);
- * 2. 清单每行的放置档位/资产模式/资产原因与引擎读数**同一份真相**(接缝只有一处);
- * 3. 尺寸层级在**引擎实体**上成立(监测点标记 > 设备符号),且来自接缝而非写死;
- * 4. 设备名称默认截断:`min-width:0 + nowrap + ellipsis` 在**计算样式**里生效,
- * 且长名字下清单**不横向溢出**(`scrollWidth <= clientWidth`);
- * 5. 降级可见性:`renderable=false` 的设备**不在引擎实体里**,但**在清单里**且带原因文案;
- * 6. 全程无页面级报错。
- *
- * 运行(需先起 `yarn dev`;`AGENTS.md` 要求不自动跑构建/调试命令,故由维护者按需执行):
- * node .scratch/cockpit-shell-panel-layout/verify/t44-device-assets-walkthrough.mjs
- * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
- *
- * 两个沿用票 41/42/43 的真机坑:
- * · 走查标签里 `document.hidden === true`(rAF 冻结、Cesium 不渲染),所以**不点屏幕坐标**、
- * 不依赖逐帧画面,只读 DOM 与 `__cockpitMap` 的实体读数;
- * · 「真实鼠标点三维设备」这类画面观感不在本脚本承诺内(票 42/43 已记录同一环境事实)。
- */
- import { cdpLaunch } from './cdp-driver.mjs'
- 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'
- /** 与 mock 的 `device-layer` 场景一致(受控有效坐标)。 */
- const SCENARIO = 'device-layer'
- const MP_CODE = 'MP-001'
- const DEVICE_CODES = ['EQ-001', 'EQ-002']
- const results = []
- /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
- 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))
- // 本机 `puppeteer-core` 已从 profile 目录消失(`t28-fill-container.mjs` 因此直接 ERR_MODULE_NOT_FOUND),
- // 所以这里走**零依赖 CDP 驱动**(`cdp-driver.mjs`,Node 24 自带 WebSocket/fetch)。
- const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
- console.log(`Chrome: ${await browser.version()}`)
- console.log(`页面可见性 document.hidden=${await (async () => {
- const p = await browser.newPage()
- const v = await p.evaluate(() => document.hidden)
- await p.close()
- return v
- })()}(true 时 rAF 冻结,本脚本因此不依赖逐帧画面)`)
- /** 一次读全:设备清单 DOM + 计算样式 + 引擎实体读数。 */
- const readState = (page) =>
- page.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- const dev = window.__cockpitMap
- const rows = [...document.querySelectorAll('[data-device-layer-list] [data-device-layer-row]')]
- const list = document.querySelector('[data-device-layer-list]')
- const nameEl = document.querySelector('.point-detail__device-name')
- const legend = document.querySelector('[data-device-layer-legend]')
- const css = nameEl ? window.getComputedStyle(nameEl) : null
- const assetEl = (row) => row.querySelector('[data-device-asset-mode]')
- return {
- engine: (host && host.dataset.mapEngine) || '',
- // 引擎侧读数:`plan` 是接缝过完的计划行,`entities` 是**真画上去**的实体。
- associated: dev && dev.getAssociatedDevices ? dev.getAssociatedDevices() : null,
- detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
- pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '',
- total: ((document.querySelector('[data-device-layer-total]') || {}).textContent || '').trim(),
- drawn: ((document.querySelector('[data-device-layer-drawn]') || {}).textContent || '').trim(),
- legendRows: legend
- ? [...legend.querySelectorAll('[data-device-legend-key]')].map((n) => ({
- key: n.dataset.deviceLegendKey,
- count: Number(n.dataset.deviceLegendCount || 0),
- }))
- : [],
- // 清单行的可断言面(票 44 新增:资产模式/原因从行上读,不从画面猜)。
- rows: rows.map((row) => {
- const asset = assetEl(row)
- return {
- placement: row.dataset.deviceLayerRow || '',
- code: row.dataset.deviceLayerCode || '',
- pointCode: row.dataset.deviceLayerPoint || '',
- name: ((row.querySelector('.point-detail__device-name') || {}).textContent || '').trim(),
- placementText: ((row.querySelector('.point-detail__device-placement') || {}).textContent || '').trim(),
- assetMode: asset ? asset.dataset.deviceAssetMode || '' : '',
- assetReason: asset ? asset.dataset.deviceAssetReason || '' : '',
- assetText: asset ? (asset.textContent || '').trim() : '',
- }
- }),
- // 截断边界读**计算样式**(写死的 CSS 可能被更高优先级覆盖,源码文本不算证据)。
- nameCss: css
- ? {
- minWidth: css.minWidth,
- overflow: css.overflow,
- whiteSpace: css.whiteSpace,
- textOverflow: css.textOverflow,
- width: nameEl.getBoundingClientRect().width,
- scrollWidth: nameEl.scrollWidth,
- }
- : null,
- listOverflow: list ? { scrollWidth: list.scrollWidth, clientWidth: list.clientWidth } : null,
- }
- })
- /** 打开页面并走真实进入引导;`scenario` 为 null 时**不注入**(走 mock 默认样例 = 现网正式边界)。 */
- async function openPage(scenario = SCENARIO) {
- 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()}`)
- })
- if (scenario) {
- await page.evaluateOnNewDocument((name) => {
- window.__COCKPIT_MOCK_SCENARIO__ = name
- }, scenario)
- }
- 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(() => {})
- if (await page.$('[data-cockpit-intro]')) {
- await page.click('[data-cockpit-intro]')
- await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
- }
- await sleep(1800)
- return { page, errors }
- }
- /** 点「加载下一页」直到翻完(标记要等分页到位才出现)。 */
- async function advanceUntilSettled(page, maxSteps = 12) {
- for (let step = 0; step < maxSteps; step += 1) {
- const phase = await page.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- return (host && host.dataset.mapSummary) || ''
- })
- if (['complete', 'empty', 'failed', 'expired'].includes(phase)) return
- 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) return
- await sleep(400)
- }
- }
- const { page, errors } = await openPage()
- await advanceUntilSettled(page)
- // ── 走**真实用户路径**选中监测点:外壳递进来的统一入口(票 41),不是另造一条定位路径。
- await page.evaluate((code) => {
- const dev = window.__cockpitMap
- if (dev && dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
- }, MP_CODE)
- await page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 20000 }).catch(() => {})
- await sleep(2500)
- const state = await readState(page)
- const plan = (state.associated && state.associated.plan) || []
- const entities = (state.associated && state.associated.entities) || []
- // 引擎实体**现在也回接缝结论**(`shape`/`size`/`assetMode`/`assetReason`,见 `readAssociatedDevices`),
- // 所以"地图上那台设备画成了什么"可以直接读实体 —— 比读计划行强一档:计划行只是"打算画什么"。
- const drawnCodes = entities.map((e) => e && e.deviceCode).filter(Boolean)
- const entityByCode = new Map(entities.map((e) => [e.deviceCode, e]))
- const planByCode = new Map(plan.map((r) => [r.code, r]))
- // 1) 清单真的渲染出来了(DOM 层证据,不只是 store 里的行)。
- check('W1-device-list-rendered',
- state.rows.length === DEVICE_CODES.length && state.rows.every((r) => DEVICE_CODES.includes(r.code)),
- `清单 ${state.rows.length} 行:${state.rows.map((r) => `${r.code}(${r.placement})`).join(' ')};总数读数「${state.total}」、已上图「${state.drawn}」`)
- // 2) 接缝只有一处真相:**清单 DOM 上的**资产模式/原因与**地图实体上的**逐台相同。
- // 不一致就说明"地图画的是A、面板说的是B"——正是可替换接缝要防的那种分裂。
- // 已上图的那台以**实体**为准("画成了什么");没上图的那台退回计划行(它本来就没实体)。
- // 判据要求 DOM 侧非空:否则"两边都是空串"也会算相等,那是恒真。
- const seamMismatch = state.rows.filter((r) => {
- const drawn = entityByCode.get(r.code)
- const p = drawn || planByCode.get(r.code)
- if (!p || !r.assetMode || !r.assetReason) return true
- return p.assetMode !== r.assetMode || p.assetReason !== r.assetReason
- })
- check('W2-panel-and-map-same-seam',
- seamMismatch.length === 0 && plan.length === DEVICE_CODES.length,
- `清单 ${state.rows.length} 行与${entities.length ? '地图实体' : '引擎计划'}同源:${state.rows.map((r) => {
- const p = entityByCode.get(r.code) || planByCode.get(r.code) || {}
- return `${r.code}:${r.assetMode}/${r.assetReason}==${p.assetMode}/${p.assetReason}`
- }).join(' ')}`)
- // 3) 资产降级**在详情里可见**(验收第 5 条:详情标明视觉资源异常)。
- // 本期正式图片素材未提供 ⇒ 每台都该有一行原因文案,且模式的取值集合里根本没有 model。
- const allHaveReason = state.rows.every((r) => r.assetText.length > 0)
- check('W3-degradation-visible-in-detail',
- allHaveReason && state.rows.every((r) => (r.assetMode || '') !== 'model'),
- `每行都带资产原因文案:${state.rows.map((r) => `${r.code}「${r.assetText}」`).join(' ')}`)
- // 4) 尺寸层级在**真机读数**上成立:监测点符号 > 设备符号(规格验收第 2 条)。
- // 两边的像素尺寸都从**引擎里的 billboard 图片**取:`markerSvg` 把 width/height 写进
- // SVG data URL,所以这里读的是"真画上去的那张图有多大",不是两份 JS 常量互相比。
- const pointWidths = await page.evaluate(() => {
- const dev = window.__cockpitMap
- const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
- const images = (markers && markers.markerImages) || {}
- const widthOf = (dataUrl) => {
- const m = /width%3D%22(\d+(?:\.\d+)?)%22/.exec(dataUrl || '') || /width="(\d+(?:\.\d+)?)"/.exec(decodeURIComponent(dataUrl || ''))
- return m ? Number(m[1]) : null
- }
- return Object.keys(images).map((key) => ({ key, width: widthOf(images[key].image) }))
- })
- const pointWidth = (pointWidths.find((p) => typeof p.width === 'number') || {}).width
- // 设备侧尺寸读**引擎实体的 billboard 真实宽高**(不是计划值),与监测点标记图片宽同一个量纲。
- const entitySizes = entities.map((e) => e && e.size).filter((s) => typeof s === 'number')
- check('W4-size-hierarchy-on-engine',
- typeof pointWidth === 'number' && entitySizes.length > 0 && entitySizes.every((s) => s < pointWidth),
- `监测点标记图片宽 ${pointWidth}px(引擎 billboard);设备实体真实尺寸 ${entitySizes.join('/')}(全部更小)`)
- // 5) 长文本默认截断:计算样式真的生效,且清单不横向溢出(验收第 3 条)。
- check('W5-label-truncates-not-overflows',
- !!state.nameCss &&
- state.nameCss.minWidth === '0px' &&
- ['hidden', 'clip'].includes(state.nameCss.overflow) &&
- state.nameCss.whiteSpace === 'nowrap' &&
- state.nameCss.textOverflow === 'ellipsis' &&
- !!state.listOverflow &&
- state.listOverflow.scrollWidth <= state.listOverflow.clientWidth + 1,
- `计算样式 min-width=${state.nameCss && state.nameCss.minWidth} overflow=${state.nameCss && state.nameCss.overflow} white-space=${state.nameCss && state.nameCss.whiteSpace} text-overflow=${state.nameCss && state.nameCss.textOverflow};清单 scrollWidth=${state.listOverflow && state.listOverflow.scrollWidth} <= clientWidth=${state.listOverflow && state.listOverflow.clientWidth}`)
- // 6) 降级可见性(验收第 4/5 条)**不在这里判**:本趟两台设备坐标都有效,走不到降级分支,
- // 在这里写一条 `blockedRows.length === 0 ? true : …` 就是恒真摆设。真正的那趟在下面用
- // **默认样例**(现网边界 `renderable=false`)跑,见 W6/W9/W10。
- // 7) 关联设备图例独立于监测点图例(验收第 7 条后半)。
- // **注意图例是哪一个**:`[data-device-layer-legend]` 渲染的是**放置档位**图例
- // (`deviceLayer.associatedLegend`,drawn/no-location/… 7 类),不是设备**状态**词表的 6 行。
- // 所以判据是"两张图例的词表不同",而不是"设备图例有 6 行"(本脚本初版就把这个写错了,
- // 真机跑出来 7 行才暴露)。点位图例的词表是状态键(ALARM/WARNING/…),两者不该有交集。
- const pointLegendKeys = Object.keys((await page.evaluate(() => {
- const dev = window.__cockpitMap
- const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
- return (markers && markers.byStatus) || {}
- })) || {})
- const placementKeys = state.legendRows.map((r) => r.key)
- const shared = placementKeys.filter((k) => pointLegendKeys.includes(k))
- check('W7-device-legend-independent',
- placementKeys.length === 7 && state.legendRows.some((r) => r.key === 'terrain-pending') && shared.length === 0,
- `设备图层图例 ${placementKeys.length} 行(放置档位):${placementKeys.join('/')};点位图例 ${pointLegendKeys.length} 行:${pointLegendKeys.join('/') || '无'};交集 ${shared.length}`)
- // 8) 全程无页面级报错。
- check('W8-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
- await page.close()
- // ── 第二趟:**默认样例**(照抄现网正式边界:设备 `renderable=false`)──
- // 第一趟两台设备坐标都有效,走不到降级分支;所以"降级可见"这条断言必须另跑一趟,
- // 否则它只是个恒真的摆设(本脚本初版就是这样,W6 靠 `blockedRows.length === 0 ? true` 蒙混过关)。
- {
- const pass = await openPage(null)
- await advanceUntilSettled(pass.page)
- await pass.page.evaluate((code) => {
- const dev = window.__cockpitMap
- if (dev && dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
- }, MP_CODE)
- await pass.page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 20000 }).catch(() => {})
- await sleep(2200)
- const state2 = await readState(pass.page)
- const entities2 = (state2.associated && state2.associated.entities) || []
- const drawn2 = entities2.map((e) => e.deviceCode).filter(Boolean)
- const degraded = state2.rows.filter((r) => r.placement !== 'drawn')
- // 「降级」不止坐标这一类:`inconsistent`(与当前快照不一致)也属降级,且文案各不相同。
- // 所以判据分两层:**每一行都要有原因文案**;只有坐标类的两档才要求文案点到"坐标/位置"。
- const coordinateDegraded = degraded.filter((r) => r.placement === 'no-location' || r.placement === 'terrain-pending')
- check('W6-degraded-listed-but-not-drawn',
- degraded.length > 0 &&
- degraded.every((r) => !drawn2.includes(r.code) && r.placementText.length > 0) &&
- coordinateDegraded.every((r) => /坐标|位置/.test(r.placementText)),
- `默认样例(现网边界)清单 ${state2.rows.length} 行,其中降级 ${degraded.length} 行:${
- degraded.map((r) => `${r.code}(${r.placement})「${r.placementText}」`).join(' ')
- };引擎实体 ${drawn2.join('/') || '无'}`)
- // 现网边界:后端目前无可信校核源,真实关联设备**统一不上图** —— 这条要如实钉住,
- // 免得哪天"能上图了"却没人发现(票 42 验收第 2 条原句:样例不得冒称正式已能上图)。
- check('W9-production-boundary-draws-nothing',
- drawn2.length === 0,
- `默认样例下引擎实体数 ${drawn2.length}(现网无可信校核源 ⇒ 设备不上图,只在清单里说明原因)`)
- // 降级原因也要**在详情里可见**(第 5 条),默认样例同样要满足。
- check('W10-degraded-reason-visible-in-detail',
- state2.rows.every((r) => r.assetText.length > 0),
- state2.rows.map((r) => `${r.code}「${r.assetText}」`).join(' '))
- check('W11-default-scenario-no-page-errors',
- pass.errors.length === 0,
- pass.errors.length ? pass.errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
- await pass.page.close()
- }
- await browser.close()
- const failed = results.filter((r) => !r.pass)
- console.log('')
- console.log(`T44 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const r of failed) console.log(` · ${r.id}`)
- process.exit(1)
- }
- console.log('T44 真机走查全部通过。')
|