/** * T11 真机走查:设备详情的六个分区按设备编码加载、分区独立成败(票 11)。 * * 逻辑脚本(t11-equipment-detail.mjs)已经把分区映射、视图占位符、store 按编码取数、 * 换设备竞态、分区独立与图片单值测透了;这里只补**只有在真机才成立**的部分: * 1. 选设备卡片后,界面**真的**按这台设备的编码把六个分区画出来(DOM 实证,不是读 store); * 2. 分区级部分失败时,坏掉的那块显示失败措辞,**好的三块仍然画出真实内容** * —— 这是本票验收第 3 条唯一在真机上才能验的形态; * 3. 空 / 过期场景下每块分区都有明确 UI(验收第 4 条); * 4. 最新图片只画出**一张** ``(验收第 5 条); * 5. 打开设备详情不改动地图上的标记数量(票 10 的常驻语义不回退)。 * * 运行(需先起 `yarn dev`;本机 dev 端口见启动输出,默认 8000/8001): * node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时) * * **场景注入的关键**:用 `__COCKPIT_MOCK_ONLY__` 把异常样例**只**施加在设备详情聚合这一个 * 端点上,地图与点位摘要保持 success。否则全局 `empty` 会把地图也清空 —— 那就没有可点的 * 标记,走查会退化成"点不到东西"的假失败(票 08/09 都踩过这类坑)。这样每个场景点下去 * 都是同一台设备的六个分区,只有聚合内容变了,对比才有意义。 * * 演示开关(宿主全局键注入,不改代码重新构建;场景观察最直观的一屏是 equipment-partial): * `__COCKPIT_MOCK_SCENARIO__='equipment-partial'` + `__COCKPIT_MOCK_ONLY__` 取设备详情聚合路径 * —— 档案/工单/图片各自失败,指标/预警/报警照常成功(一块坏了不遮其它块) * `...SCENARIO__='empty'` —— 六块都是"没有数据" * `...SCENARIO__='stale'` —— 指标块过期但保留旧值 * (`__COCKPIT_MOCK_ONLY__` 的确切写法见下方 `AGGREGATE_ONLY` 常量) */ 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' /** 只让设备详情聚合走异常样例,地图与点位摘要保持 success(见文件头说明)。 */ const AGGREGATE_ONLY = '/equipments/*/detail-aggregate' 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-t11-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()}`) /** * 一次读全:设备详情浮层 DOM(层级/表头/六块分区/图片)+ 地图侧口径(标记计划、宿主 dataset)。 * 分区内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。 */ const readDetail = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const card = document.querySelector('[data-point-detail]') const text = (selector) => { const el = card && card.querySelector(selector) return el ? el.textContent.trim() : '' } const dataset = (el) => (el && el.dataset) || {} const dev = window.__cockpitMap const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null return { hasCard: !!card, level: card ? card.dataset.detailLevel : '', pointCode: card ? card.dataset.pointCode : '', deviceCode: card ? card.dataset.deviceCode : '', deviceName: text('[data-device-name]'), deviceCodeShown: text('[data-device-code-value]'), deviceLoading: !!text('[data-device-loading]'), deviceUnavailable: text('[data-device-unavailable]'), hasRetry: !!(card && card.querySelector('[data-device-retry]')), hasBack: !!(card && card.querySelector('[data-detail-back]')), // ---- 六个分区:每块自带状态、内容与占位说明 ---- sections: card ? Array.from(card.querySelectorAll('[data-equipment-section]')).map((el) => ({ name: el.dataset.equipmentSection || '', // 分区容器自己的状态(data-panel-*,避免与 SectionStateTag 的 data-section-* 撞名) state: el.dataset.panelState || '', retained: el.dataset.panelRetained === 'true', // 分区自己的状态标签(SectionStateTag 的 data-section-*) tagName: dataset(el.querySelector('[data-section-name]')).sectionName || '', tagState: dataset(el.querySelector('[data-section-state]')).sectionState || '', tagText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(), title: ((el.querySelector('.equipment-section__title') || {}).textContent || '').trim(), rows: el.querySelectorAll('[data-archive-row], [data-metric], [data-event], [data-work-order]').length, imageCount: el.querySelectorAll('[data-latest-image] img').length, placeholder: ((el.querySelector('[data-section-placeholder]') || {}).textContent || '').trim(), // 缺失说明:界面上只有条数与 tooltip(字段路径不进可见文案) missingText: ((el.querySelector('[data-section-missing]') || {}).textContent || '').trim(), missingCount: Number(dataset(el.querySelector('[data-section-missing]')).missingCount || 0), missingTitle: (el.querySelector('[data-section-missing]') || {}).title || '', })) : [], archiveValues: card ? Array.from(card.querySelectorAll('[data-archive-row] dd')).map((el) => el.textContent.trim()) : [], metricValues: card ? Array.from(card.querySelectorAll('[data-metric-value]')).map((el) => el.textContent.trim()) : [], eventLevels: card ? Array.from(card.querySelectorAll('[data-event-level]')).map((el) => el.textContent.trim()) : [], workOrderStates: card ? Array.from(card.querySelectorAll('[data-work-order]')).map((el) => ({ code: el.dataset.workOrder || '', state: el.dataset.workOrderState || '', text: ((el.querySelector('.equipment-section__item-name') || {}).textContent || '').trim(), })) : [], imageSrcs: card ? Array.from(card.querySelectorAll('[data-latest-image] img')).map((el) => el.getAttribute('src') || '') : [], // ---- 地图侧 ---- engine: (host && host.dataset.mapEngine) || '', summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0), summaryPhase: (host && host.dataset.mapSummary) || '', dsMarkers: Number((host && host.dataset.mapMarkers) || 0), dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0), picked: (host && host.dataset.mapPicked) || '', entityCount: markers ? markers.onMap : -1, cardHosts: document.querySelectorAll('[data-point-detail]').length, mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, } }) /** 打开页面并走真实的进入引导(点一下「点击进入系统」)。 */ async function openPage(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()}`) }) await page.evaluateOnNewDocument( ({ name, only }) => { window.__COCKPIT_MOCK_SCENARIO__ = name window.__COCKPIT_MOCK_ONLY__ = only window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 } }, { name: scenario, only: AGGREGATE_ONLY }, ) 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(1500) return { page, errors } } /** 点「加载下一页」直到翻完(地图上的监测点标记要等它出现)。 */ async function advanceUntilSettled(page, maxSteps = 12) { for (let step = 0; step < maxSteps; step += 1) { const state = await readDetail(page) if (['complete', 'empty', 'failed', 'expired'].includes(state.summaryPhase)) 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.summaryLoaded 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 readDetail(page) } /** 等相机真的落在目标上(阻尼飞行途中点屏幕中心必然点空)。 */ async function waitForCameraNear(page, target, timeout = 25000) { await page .waitForFunction( ({ lon, lat }) => { const dev = window.__cockpitMap const state = dev && dev.getCameraState ? dev.getCameraState() : null const shown = state && state.shownPivot return !!shown && Math.abs(shown.lon - lon) < 2e-4 && Math.abs(shown.lat - lat) < 2e-4 }, { timeout }, { lon: target.lon, lat: target.lat }, ) .catch(() => {}) } /** 相机中心对准第一个监测点标记。 */ async function focusOnPoint(page) { const target = await page.evaluate(() => { const dev = window.__cockpitMap const marker = dev.getPointMarkers().markers.find((item) => item.kind === 'monitoring-point') if (!marker) return null dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 }) return { lon: marker.longitude, lat: marker.latitude, code: marker.code } }) if (target) await waitForCameraNear(page, target) return target } /** 用引擎拾取在相机中心附近找监测点标记的真实屏幕坐标(贴地标记与中心有高程差)。 */ async function locatePoint(page, radius = 300, stride = 10) { return page.evaluate( ([max, step]) => { const dev = window.__cockpitMap const canvas = document.querySelector('.cockpit-shell__map canvas') const center = dev.projectCameraPivot() if (!center || !canvas) return { x: 0, y: 0, code: '' } for (let dy = -max; dy <= max; dy += step) { for (let dx = -max; dx <= max; dx += step) { const x = Math.round(center.x + dx) const y = Math.round(center.y + dy) const hitElement = document.elementFromPoint(x, y) if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue const hit = dev.pickPoint(x, y) if (hit && hit.kind === 'monitoring-point') return { x, y, dx, dy, code: hit.code } } } return { x: 0, y: 0, dx: 0, dy: 0, code: '' } }, [radius, stride], ) } /** * 打开设备详情:真实鼠标点监测点标记 → 点位摘要 → 点第一张设备卡片 → 设备级。 * 这条路径是 CONTEXT.md 的「两级详情」正路,也是用户实际会走的。 */ async function openDeviceDetail(scenario) { const { page, errors } = await openPage(scenario) await advanceUntilSettled(page) const target = await focusOnPoint(page) const locatedPoint = await locatePoint(page) if (await page.$('[data-detail-close]')) { await page.click('[data-detail-close]') await sleep(250) } if (locatedPoint.code) { await page.mouse.click(locatedPoint.x, locatedPoint.y) await sleep(700) } const summary = await readDetail(page) const cards = await page.evaluate(() => Array.from(document.querySelectorAll('[data-device-card]')).map((el) => el.dataset.deviceCode || ''), ) if (cards.length) { await page.click('[data-device-card="0"]') // 等六块分区真的画出来(聚合要发一次请求)。 await page.waitForSelector('[data-equipment-section]', { timeout: 10000 }).catch(() => {}) await sleep(600) } return { page, errors, target, locatedPoint, summary, cards, detail: await readDetail(page) } } const SECTION_ORDER = ['archive', 'metrics', 'warnings', 'alarms', 'workOrders', 'latestImage'] const sectionOf = (detail, name) => detail.sections.find((item) => item.name === name) || null try { // ================================================================ A 成功场景:六块分区按设备编码画出来 { const { page, errors, cards, detail } = await openDeviceDetail('success') const code = cards[0] || '' check( 'A1-device-card-enters-equipment-level', !!code && detail.hasCard && detail.level === 'equipment' && detail.deviceCode === code, code ? `选设备卡片 ${code} → 层级 ${detail.level}` : '点位摘要里没有设备卡片', ) check( 'A2-six-sections-rendered-in-order', detail.sections.length === 6 && detail.sections.map((item) => item.name).join(',') === SECTION_ORDER.join(','), `六块分区:${detail.sections.map((item) => item.title).join(' / ')}`, ) check( 'A3-every-section-has-its-own-status-tag', detail.sections.length === 6 && detail.sections.every((item) => !!item.tagName && !!item.tagState && !!item.tagText) && detail.sections.every((item) => item.tagName === item.name), detail.sections.map((item) => `${item.name}:${item.tagState}/${item.tagText}`).join(' · '), ) check( 'A4-loaded-by-device-code', detail.deviceCode === code && detail.deviceCodeShown === code && detail.deviceLoading === false, `浮层表头 ${detail.deviceCodeShown} = 选中的设备 ${code},且已不在加载态`, ) const archive = sectionOf(detail, 'archive') const metrics = sectionOf(detail, 'metrics') const warnings = sectionOf(detail, 'warnings') const alarms = sectionOf(detail, 'alarms') const orders = sectionOf(detail, 'workOrders') const image = sectionOf(detail, 'latestImage') check( 'A5-archive-has-real-rows', archive && archive.rows >= 10 && detail.archiveValues.some((value) => value.includes('污水环境监测设备')), `档案 ${archive && archive.rows} 行,含设备名`, ) check( 'A6-metrics-have-readouts-including-real-zero', metrics && metrics.rows >= 2 && detail.metricValues.length >= 2 && detail.metricValues.some((value) => value.startsWith('0')), `指标 ${metrics && metrics.rows} 条:${detail.metricValues.join(' / ')}(含真 0 读数,没被当成缺失)`, ) check( 'A7-warnings-and-alarms-listed', warnings && warnings.rows >= 1 && alarms && alarms.rows >= 1 && detail.eventLevels.every((level) => level.endsWith('级')), `预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条 · 等级 ${detail.eventLevels.join('/')}`, ) check( 'A8-work-orders-show-state-name', orders && orders.rows >= 1 && detail.workOrderStates.every((order) => order.text.includes('处理中')), `工单 ${orders && orders.rows} 张:${detail.workOrderStates.map((order) => `${order.code}=${order.text}`).join(' · ')}`, ) check( 'A9-latest-image-is-exactly-one', image && image.imageCount === 1 && detail.imageSrcs.length === 1 && !!detail.imageSrcs[0], `最新图片分区画出 ${detail.imageSrcs.length} 张 :${detail.imageSrcs.join(', ')}`, ) check( 'A10-detail-does-not-rebuild-map', detail.cardHosts === 1 && detail.mapHosts === 1 && detail.entityCount === detail.dsPlan, `浮层 ${detail.cardHosts} 个 · 地图宿主 ${detail.mapHosts} 个 · 实体 ${detail.entityCount} = 计划 ${detail.dsPlan}(地图节点未重建)`, ) // 票 05 走过一次的坑:界面漏出内部代号。真机取一份**整卡可见文本**来查, // 而不是查单个 data-* 节点 —— 漏在标题、meta 行或占位说明里同样要被抓到。 const cardText = await page.evaluate(() => (document.querySelector('[data-point-detail]') || {}).innerText || '') const codeLeaks = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY'] .filter((code) => new RegExp(`\\b${code}\\b`).test(cardText)) // 字段路径(`archive.deviceName` 这类)也属接口词汇,不该出现在可见文案里。 const pathLeaks = (cardText.match(/\b[a-zA-Z]+\.[a-zA-Z]+\b/g) || []).filter((token) => !token.includes('.webp') && !token.includes('.js')) check( 'A10b-visible-text-has-no-internal-codes', codeLeaks.length === 0 && pathLeaks.length === 0, codeLeaks.length || pathLeaks.length ? `可见文案泄漏:代号 ${codeLeaks.join(',') || '无'} · 字段路径 ${pathLeaks.join(',') || '无'}` : '整卡可见文本零内部代号、零字段路径(缺失只报条数,路径在 tooltip 里)', ) check('A11-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ================================================================ B 分区级部分失败:坏块不遮好块 { const { page, errors, cards, detail } = await openDeviceDetail('equipment-partial') if (!cards.length) { check('B0-partial-scenario-has-device-card', false, '点位摘要里没有设备卡片') } else { const archive = sectionOf(detail, 'archive') const metrics = sectionOf(detail, 'metrics') const warnings = sectionOf(detail, 'warnings') const alarms = sectionOf(detail, 'alarms') const orders = sectionOf(detail, 'workOrders') const image = sectionOf(detail, 'latestImage') check( 'B1-broken-sections-report-failure', archive && archive.state === 'failed' && orders && orders.state === 'failed' && image && image.state === 'failed', `坏块:档案=${archive && archive.state} 工单=${orders && orders.state} 图片=${image && image.state}`, ) check( 'B2-broken-sections-say-so-in-words', archive && archive.placeholder.includes('加载失败') && orders.placeholder.includes('加载失败') && image.placeholder.length > 0, `坏块措辞:「${archive && archive.placeholder}」`, ) check( 'B3-healthy-sections-still-render-real-content', metrics && metrics.state === 'success' && metrics.rows >= 2 && detail.metricValues.length >= 2 && warnings && warnings.state === 'success' && warnings.rows >= 1 && alarms && alarms.state === 'success' && alarms.rows >= 1, `好块照常:指标 ${metrics && metrics.rows} 条(${detail.metricValues.join('/')})· 预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条`, ) check( 'B4-failure-does-not-blank-the-card', detail.sections.length === 6 && detail.level === 'equipment' && detail.deviceCode === cards[0], `六块分区都在(${detail.sections.map((item) => item.state).join('/')}),浮层没白屏也没自动关掉`, ) check( 'B5-guard-has-teeth', detail.sections.filter((item) => item.state === 'failed').length === 3 && detail.sections.filter((item) => item.state === 'success').length === 3, '恰好 3 坏 3 好(因此 B3「好块仍在」不是恒真)', ) check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) } await page.close() } // ================================================================ C 空场景:每块都有明确 UI { const { page, errors, cards, detail } = await openDeviceDetail('empty') if (!cards.length) { check('C0-empty-scenario-has-device-card', false, '点位摘要里没有设备卡片') } else { check( 'C1-empty-sections-all-say-no-data', detail.sections.length === 6 && detail.sections.every((item) => item.state === 'empty') && detail.sections.every((item) => item.placeholder === '该分区当前没有数据'), detail.sections.map((item) => `${item.name}=${item.state}`).join(' · '), ) check( 'C2-empty-does-not-fake-zero-values', detail.metricValues.length === 0 && detail.archiveValues.length === 0 && detail.workOrderStates.length === 0 && detail.imageSrcs.length === 0, '空场景下没有任何伪造的 0 / 空行 / 空图', ) check('C3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) } await page.close() } // ================================================================ D 过期场景:有值 + 过期标识 { const { page, errors, cards, detail } = await openDeviceDetail('stale') if (!cards.length) { check('D0-stale-scenario-has-device-card', false, '点位摘要里没有设备卡片') } else { const metrics = sectionOf(detail, 'metrics') const archive = sectionOf(detail, 'archive') check( 'D1-stale-section-flagged-and-valued', metrics && metrics.state === 'stale' && metrics.retained === true && metrics.rows >= 2 && detail.metricValues.length >= 2, `指标块 ${metrics && metrics.state}(retained=${metrics && metrics.retained})但仍画出 ${detail.metricValues.length} 条读数`, ) check( 'D2-stale-not-confused-with-empty', metrics && metrics.placeholder === '' && metrics.tagText.includes('过期'), `过期块说的是「${metrics && metrics.tagText}」,不是「无数据」`, ) check( 'D3-stale-does-not-affect-other-sections', archive && archive.state === 'success' && archive.rows >= 10, `档案块照常 ${archive && archive.state}(${archive && archive.rows} 行)—— 过期不牵连`, ) check('D4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) } await page.close() } // ================================================================ E 整包失败:一句实话 + 重试入口 { const { page, errors, cards, detail } = await openDeviceDetail('failed') if (!cards.length) { check('E0-failed-scenario-has-device-card', false, '点位摘要里没有设备卡片') } else { check( 'E1-whole-failure-says-one-truthful-thing', detail.level === 'equipment' && !!detail.deviceUnavailable && detail.sections.length === 0, `整包失败时说的是「${detail.deviceUnavailable}」(不渲染六块,因此不会逐块谎报"其它分区不受影响")`, ) check( 'E2-whole-failure-offers-retry', detail.hasRetry === true && detail.deviceLoading === false, '给了「重新加载」入口,不是死胡同', ) check( 'E3-whole-failure-does-not-blank-or-close', detail.hasCard === true && detail.cardHosts === 1 && detail.deviceCode === cards[0], `浮层仍在、表头仍带设备 ${detail.deviceCode}(没白屏、没自动关窗)`, ) check('E4-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(`\nT11 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`) process.exit(failed.length ? 1 : 0)