/** * 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 真机走查全部通过。')