/** * T10 真机走查:点地图上的监测点 → 点位摘要 + 设备卡片 → 选卡片进设备详情(票 10)。 * * 逻辑脚本(t10-point-detail.mjs)已经把两级状态机、摘要/卡片视图、store 取数与竞态、 * 「关联设备不重复上图」的守卫测透了;这里只补**只有在真机才成立**的部分: * 1. 地图上的点位标记**真的能被鼠标拾到**(pointerdown/up → Cesium 拾取 → 打开浮层整条路); * 2. 拖拽地图**不会**误触发点选(票 03 的左键平移手感不能被新功能打坏); * 3. 点位摘要五要素真的渲染出来,且状态标签与地图标记同源(同形状同色键同文字); * 4. 设备卡片顺序 = 后端返回顺序(样例刻意逆状态优先级,界面一排序就露馅); * 5. 选卡片进第二级、返回第一级、关闭浮层;打开详情不改动地图上的标记数量。 * * 运行(需先起 `yarn dev`;本机 dev 端口见启动输出,默认 8000/8001): * node .scratch/cockpit-shell-panel-layout/verify/t10-point-detail-walkthrough.mjs * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时) * * 两个只有真机才踩得到的坑(都写进了下面的实现,替换成"直接点屏幕中心"就会假失败): * · 相机落位是**阻尼飞行**(跨县要 1~2 秒),飞行途中中心还在半路上 —— 必须等 `shownPivot` * 贴到目标再点,固定 sleep 会点在飞行路径上; * · 贴地标记与"高度 0 的中心"之间有地形高程差 —— 所以先用引擎拾取在中心附近 * **找到标记的真实屏幕坐标**,再用真实鼠标点那个坐标(点选链路本身仍是 DOM 事件)。 * * 演示开关(宿主全局键注入,不改代码重新构建): * `window.__COCKPIT_MOCK_SCENARIO__ = 'point-detail-order'` —— 设备卡片逆状态优先级排列 * `window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'` —— 六态点位 + 一个可上图的未关联设备 * 人工演示同一件事:控制台里 `__cockpitMap.getPointMarkers()` 看标记计划、 * `__cockpitMap.pickPoint(x, y)` 用屏幕坐标走同一条点选链路。 */ 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-t10-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)。 */ 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 tag = (el) => el ? { label: ((el.querySelector('.status-tag__label') || {}).textContent || '').trim(), shape: el.dataset.shape || '', tone: el.dataset.tone || '', hasShapeSvg: !!el.querySelector('.status-tag__shape'), } : null 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 : '', state: card ? card.dataset.pointDetailState : '', name: text('[data-point-name]'), code: text('[data-point-code-value]'), reason: text('[data-point-reason]'), count: text('[data-point-device-count]'), status: tag(card && card.querySelector('[data-point-status]')), cards: card ? Array.from(card.querySelectorAll('[data-device-card]')).map((el) => ({ code: el.dataset.deviceCode || '', status: tag(el.querySelector('.status-tag')), reason: ((el.querySelector('.point-detail__card-reason') || {}).textContent || '').trim(), })) : [], deviceName: text('[data-device-name]'), deviceCodeShown: text('[data-device-code-value]'), hasBack: !!(card && card.querySelector('[data-detail-back]')), unassociatedNote: text('[data-device-unassociated]'), deviceBody: text('[data-device-detail-body]'), // ---- 地图侧 ---- 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, planMarkers: markers ? markers.markers.map((item) => ({ code: item.code, kind: item.kind, status: item.status, shape: item.shape, tone: item.tone, label: item.label })) : [], cardHosts: document.querySelectorAll('[data-point-detail]').length, mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, } }) /** * 打开页面并**走真实的进入引导**(点一下「点击进入系统」→ 飞到沅陵预设)。 * 不注入 `__COCKPIT_CONFIG__`:注入了就不再拉 `public/cockpit/config.json`, * 家视角会退化成 (0,0)(配置层把未配置的 home 归一化成 null,`readCoord(null)` 得到 0)。 */ 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()}`) }) 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(() => {}) // 进入引导:真实用户路径(点一下进入系统)。 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 focusOnMarker(page, kind) { const target = await page.evaluate((wantedKind) => { const dev = window.__cockpitMap const marker = dev.getPointMarkers().markers.find((item) => item.kind === wantedKind) if (!marker) return null dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 }) return { lon: marker.longitude, lat: marker.latitude, code: marker.code, status: marker.status, shape: marker.shape, tone: marker.tone, label: marker.label } }, kind) if (target) await waitForCameraNear(page, target) return target } /** * 在相机中心附近找到一个**真实标记**的屏幕坐标(先挑落点,再真点)。 * * 用引擎拾取来找:它和真实点击走的是同一个 `scene.pick`,所以找到的坐标点下去必然命中; * 「地形高程差」与「阻尼滞后」都因此不再是问题。每个候选点先确认鼠标落点确实是画布 * (浮层/图例盖住的地方不点),命中即返回。 */ async function locateMarker(page, kind, radius = 300, stride = 10) { return page.evaluate( ([wantedKind, 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: '', kind: '', center } 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 === wantedKind) return { x, y, dx, dy, code: hit.code, kind: hit.kind, center } } } return { x: 0, y: 0, dx: 0, dy: 0, code: '', kind: '', center } }, [kind, radius, stride], ) } /** 关掉可能已经被「找点」打开的浮层,好让真实点击成为唯一证据。 */ async function closeCard(page) { if (await page.$('[data-detail-close]')) { await page.click('[data-detail-close]') await sleep(250) } } try { // ================================================================ A-F 顺序样例 { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'point-detail-order' window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const settled = await advanceUntilSettled(page) check('A1-engine-ready', ['ready', 'disabled'].includes(settled.engine), `engine=${settled.engine}`) check( 'A2-markers-on-map', settled.dsPlan === 2 && settled.dsMarkers === 2 && settled.entityCount === 2, `计划 ${settled.dsPlan} · dataset ${settled.dsMarkers} · 实体 ${settled.entityCount}`, ) check('A3-no-card-before-click', settled.cardHosts === 0, `点之前浮层 ${settled.cardHosts} 个`) // ---------- F 拖拽不误触发(票 03 的左键平移不能被新功能打坏)---------- const pivot = await page.evaluate(() => { const dev = window.__cockpitMap return dev && dev.projectCameraPivot ? dev.projectCameraPivot() : null }) if (pivot) { await page.mouse.move(pivot.x - 70, pivot.y) await page.mouse.down() await page.mouse.move(pivot.x + 70, pivot.y, { steps: 8 }) await page.mouse.up() } await sleep(500) const afterDrag = await readDetail(page) check( 'F1-drag-does-not-open-detail', afterDrag.cardHosts === 0 && afterDrag.picked === '', `拖拽 140px 后浮层 ${afterDrag.cardHosts} 个 · data-map-picked「${afterDrag.picked}」`, ) // ---------- B 真实鼠标点中一个监测点标记 → 点位摘要 ---------- const target = await focusOnMarker(page, 'monitoring-point') const located = await locateMarker(page, 'monitoring-point') await closeCard(page) const beforeClick = await readDetail(page) if (located.code) { await page.mouse.click(located.x, located.y) await sleep(600) } const detail = await readDetail(page) check( 'B0-marker-locatable-by-engine-pick', !!target && !!located.code && detail.planMarkers.some((item) => item.code === located.code && item.kind === 'monitoring-point'), `相机对准 ${target && target.code},引擎在中心偏移 ${located.dx},${located.dy}px 处拾到 ${located.code}(两个点位只隔约 7px,最上面那个先被拾到;偏移量就是贴地标记与中心的高程差)`, ) check( 'B1-real-click-opens-summary', !!located.code && beforeClick.cardHosts === 0 && detail.hasCard && detail.level === 'point' && detail.state === 'success', located.code ? `真实鼠标点 (${located.x},${located.y}) → 层级 ${detail.level} · 分区 ${detail.state}` : '没找到可点的标记', ) check( 'B2-card-is-the-picked-point', !!located.code && detail.picked === `monitoring-point|${located.code}` && detail.pointCode === located.code, `dataset 拾到「${detail.picked}」· 浮层显示 ${detail.pointCode}`, ) // 摘要五要素:名称 / 编码 / 状态 / 原因 / 关联设备数量。 check( 'B3-summary-five-fields', !!detail.name && detail.code === detail.pointCode && !!detail.status && !!detail.status.label && detail.reason === '流量超高' && detail.count === '3', `名称「${detail.name}」编码「${detail.code}」状态「${detail.status && detail.status.label}」原因「${detail.reason}」关联设备 ${detail.count}`, ) check( 'B4-status-tag-has-shape-and-text', !!detail.status && detail.status.hasShapeSvg && !!detail.status.shape && !!detail.status.tone && !!detail.status.label, `状态标签 ${detail.status && detail.status.tone}/${detail.status.shape}/${detail.status.label}(不只靠颜色)`, ) // 状态标签必须与**地图上那个标记**同源:形状、色键、文字逐个对齐。 const marker = detail.planMarkers.find((item) => item.code === detail.pointCode) || null check( 'B5-card-status-matches-map-marker', !!marker && !!detail.status && marker.shape === detail.status.shape && marker.tone === detail.status.tone && marker.label === detail.status.label, marker && detail.status ? `标记 ${marker.tone}/${marker.shape}/${marker.label} vs 浮层 ${detail.status.tone}/${detail.status.shape}/${detail.status.label}` : '浮层里的点位在地图标记计划里', ) // ---------- C 设备卡片:顺序 = 后端返回顺序(样例刻意逆状态优先级)---------- const ORDER = ['EQ-ORDER-NORMAL', 'EQ-ORDER-ALARM', 'EQ-ORDER-WARNING'] check( 'C1-device-card-order-is-backend-order', detail.cards.map((card) => card.code).join(',') === ORDER.join(','), `卡片顺序 ${detail.cards.map((card) => card.code).join(' → ')}(后端给的就是这个顺序:正常 → 报警 → 预警)`, ) check( 'C2-every-card-has-status-tag', detail.cards.length === 3 && detail.cards.every((card) => card.status && card.status.hasShapeSvg && !!card.status.label), detail.cards.map((card) => `${card.code}:${card.status && card.status.label}`).join(' · '), ) check('C3-card-count-equals-summary-count', detail.cards.length === Number(detail.count), `摘要 ${detail.count} 台 = 卡片 ${detail.cards.length} 张`) // ---------- E 关联设备不重复上图 ---------- const deviceCodes = detail.cards.map((card) => card.code) check( 'E1-markers-unchanged-after-detail', detail.dsMarkers === settled.dsMarkers && detail.entityCount === settled.entityCount && detail.planMarkers.length === settled.planMarkers.length, `打开详情前后:dataset ${settled.dsMarkers} → ${detail.dsMarkers} · 实体 ${settled.entityCount} → ${detail.entityCount}`, ) check( 'E2-associated-device-not-a-marker', deviceCodes.length === 3 && deviceCodes.every((code) => !detail.planMarkers.some((item) => item.code === code)), `设备 ${deviceCodes.join(', ')} 都不在标记计划里(地图上只有 ${detail.planMarkers.map((item) => item.code).join(', ')})`, ) check('E3-picked-recorded-on-host', detail.picked === `monitoring-point|${detail.pointCode}`, `data-map-picked=「${detail.picked}」`) check('E4-single-card-host', detail.cardHosts === 1 && detail.mapHosts === 1, `浮层 ${detail.cardHosts} 个 · 地图宿主 ${detail.mapHosts} 个`) // ---------- D 选卡片进第二级 → 返回 → 关闭 ---------- await page.click('[data-device-card="0"]') await sleep(300) const second = await readDetail(page) check( 'D1-select-card-enters-device-level', second.level === 'equipment' && second.deviceCode === ORDER[0] && second.deviceCodeShown === ORDER[0] && !!second.deviceName, `层级 ${second.level} · 设备 ${second.deviceCodeShown}「${second.deviceName}」`, ) check('D2-back-button-present', second.hasBack === true && !!second.deviceBody, `第二级有「返回点位」· 分区区「${second.deviceBody}」`) await page.click('[data-detail-back]') await sleep(300) const back = await readDetail(page) check( 'D3-back-returns-to-point-level', back.level === 'point' && back.pointCode === detail.pointCode && back.cards.length === 3, `返回后层级 ${back.level} · 点位 ${back.pointCode} · 卡片 ${back.cards.length} 张`, ) await page.click('[data-detail-close]') await sleep(300) const closed = await readDetail(page) check( 'D4-close-hides-card-without-touching-map', closed.cardHosts === 0 && closed.dsMarkers === detail.dsMarkers && closed.entityCount === detail.entityCount, `浮层 ${closed.cardHosts} 个 · 标记仍 ${closed.dsMarkers} 个`, ) check('D5-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ================================================================ G 未关联设备直接进设备详情 { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses' window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 } }) await advanceUntilSettled(page) const target = await focusOnMarker(page, 'unassociated-equipment') const located = await locateMarker(page, 'unassociated-equipment') await closeCard(page) const beforeClick = await readDetail(page) if (located.code) { await page.mouse.click(located.x, located.y) await sleep(600) } const detail = await readDetail(page) check( 'G1-unassociated-device-enters-device-level', !!located.code && beforeClick.cardHosts === 0 && detail.hasCard && detail.level === 'equipment' && detail.pointCode === '' && detail.deviceCode === located.code, located.code ? `点中未关联设备 ${detail.deviceCode} → 层级 ${detail.level}(没有点位可摘要)` : '没找到可点的未关联设备标记', ) check('G2-unassociated-note-visible', !!detail.unassociatedNote && !!detail.deviceBody, `提示「${detail.unassociatedNote}」· 分区区「${detail.deviceBody}」`) check('G3-no-back-button', detail.hasBack === false, '没有点位可回时不显示「返回点位」') check('G4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ================================================================ H 默认样例不受影响 { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const settled = await advanceUntilSettled(page) // 默认样例:3 点 + 2 未关联设备 = 5 个对象、其中 2 个可上图(票 07/08 的数)。 check( 'H1-default-sample-unchanged', settled.summaryLoaded === 5 && settled.dsPlan === 2 && settled.entityCount === 2, `已加载 ${settled.summaryLoaded} · 计划 ${settled.dsPlan} · 实体 ${settled.entityCount}(票 07/08 的数)`, ) check('H2-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(`\nT10 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`) process.exit(failed.length ? 1 : 0)