/** * T45 真实用户路径走查:进入系统 → **真实鼠标点地图标记** → 展开设备点 → 逐台拾取。 * * 为什么另起一个脚本:既有 `t45-overlap-walkthrough.mjs` 用 DEV 把手 `selectMonitoringPoint()` * 打开点位(那是"统一入口",但不是**用户的手**)。维护者人工走查反馈「进入系统→点开监测点→ * 展开设备点」不成立,所以这一趟必须全程用真实鼠标命中**地图上的标记**: * 1. 真实点击监测点标记(坐标由只读 `getPointMarkerTargets()` 投影给出); * 2. 断言点位详情真的打开(不是靠 DEV 入口开的); * 3. 真实 hover / click 聚合符号 → 粘性展开; * 4. 真实 click 展开后的设备点 → 选中并进设备级。 * * 场景激活走 **URL 通道** `?scenario=xxx`(人工测试同一条入口;Console 全局变量刷新即丢)。 * * **相机收敛纪律**(本脚本第一版踩到的坑):相机飞行/收敛期间投影出来的屏幕坐标会漂移 * (同一标记两次读可以差 80px),拿过期坐标去点必然打不中。所以每次点击前都必须 * 先等 `getCameraState().settled`,再**当场重读**坐标,然后立刻点。 */ import { cdpLaunch } from './cdp-driver.mjs' const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000' const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const SCENARIO = process.env.REPRO_SCENARIO || 'device-layer-overlap' const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/?scenario=${SCENARIO}#/cockpit` /** * 按场景给出期望值(两个受控样例的 K 与降级形态不同): * · `device-layer-overlap` —— 2 台同坐标,K=2,全部可逐台展开(`expanded`); * · `device-layer-overlap-degraded` —— 11 台同坐标,K=11 > maxFan(10) ⇒ 降级: * 本屏只逐台画 `maxFan` 台、其余在清单里可见(`degraded`)。 */ const EXPECT = SCENARIO === 'device-layer-overlap-degraded' ? { k: 11, fanVisible: 10, firstDevice: 'EQ-DG-001', state: 'degraded' } : { k: 2, fanVisible: 2, firstDevice: 'EQ-001', state: 'expanded' } const results = [] function check(id, pass, evidence) { results.push({ id, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 }) console.log(`Chrome: ${await browser.version()}`) 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()}`) }) /** * 真实鼠标点击一个**会漂移的投影目标**,直到满足断言或重试用尽。 * * 贴地形渲染的标记会随地形细化继续漂移(实测稳定判定后仍会漂), * 所以每次重试都**当场重读坐标**再点 —— 这也是真人的行为(没点中就再点一下)。 * @param {() => Promise<{x:number,y:number}|null>} locate 读取当前坐标 * @param {() => Promise} done 是否已达成目标 */ async function clickUntil(locate, done, tries = 6) { // 贴地形渲染的标记位置会随地形瓦片细化在几像素内抖动(实测 y 在 575↔584 之间跳), // 单点点击会偶发落空。这里在投影点附近做小网格扫描点击(真人点不中也会微调再点), // 只要**任何一个**点命中即算通过;全部落空说明产品真有问题。 const offsets = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6], [-10, 6], [10, -6]] for (let index = 0; index < tries; index += 1) { const target = await locate() if (target && Number.isFinite(target.x) && Number.isFinite(target.y)) { for (const [dx, dy] of offsets) { await page.mouse.move(1200, 300) await sleep(80) await page.mouse.move(target.x + dx, target.y + dy) await sleep(150) await page.mouse.click(target.x + dx, target.y + dy) await sleep(900) if (await done()) return true } } await sleep(400) } return false } async function waitSettled(timeout = 15000) { await page.waitForFunction(() => { const dev = window.__cockpitMap const state = dev && dev.getCameraState ? dev.getCameraState() : null return !state || state.settled === true }, { timeout }).catch(() => {}) await sleep(400) } /** * 等某个点位标记的**投影坐标稳定**后再返回它。 * * 点位标记是 `CLAMP_TO_GROUND` 贴地形渲染的:地形瓦片细化会让 `globe.getHeight()` 变化, * 投影出来的屏幕坐标随之漂移(本脚本实测两次读之间 y 差 9px —— 18px 的符号正好点空)。 * 所以不能"读一次就点",要连续两次读数一致才算稳。 */ async function stableMarker(code = 'MP-001', tries = 40) { let prev = null for (let index = 0; index < tries; index += 1) { const now = await page.evaluate((pointCode) => { const dev = window.__cockpitMap const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : [] return all.find((item) => item.visible && item.code === pointCode) || null }, code) if (now && prev && Math.abs(now.x - prev.x) < 2 && Math.abs(now.y - prev.y) < 2) return now prev = now await sleep(400) } const diag = await page.evaluate(() => { const dev = window.__cockpitMap const host = document.querySelector('.cockpit-shell__map .map-canvas') const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : [] return { targets: all.map((item) => `${item.code}@${Math.round(item.x)},${Math.round(item.y)}:${item.visible}`), engine: host ? host.dataset.mapEngine : '', markers: host ? host.dataset.mapMarkers : '', markersPlan: host ? host.dataset.mapMarkersPlan : '', summaryPhase: (() => { const s = dev && dev.mapSummary && dev.mapSummary.getState ? dev.mapSummary.getState() : null return s ? s.phase : '' })(), } }) console.log(`MARKER DIAG (未等到 ${code}): ${JSON.stringify(diag)}`) return prev } await page.goto(ENTRY_URL, { timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) if (await page.$('[data-cockpit-intro]')) { await page.click('[data-cockpit-intro]') await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {}) } await page.waitForFunction( () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 40000 }, ).catch(() => {}) // 推进地图摘要到 complete(真实用户点「加载下一页」;走查为省时用同一条装载器入口)。 await page.evaluate(() => { const dev = window.__cockpitMap if (!dev || !dev.mapSummary) return let steps = 0 const step = () => { const state = dev.mapSummary.getState() if (!state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) || steps >= 20) return dev.mapSummary.advance(); steps += 1; window.setTimeout(step, 220) } step() }) await page.waitForFunction(() => { const dev = window.__cockpitMap const state = dev && dev.mapSummary ? dev.mapSummary.getState() : null return !state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) }, { timeout: 20000 }).catch(() => {}) await waitSettled() // ── 第 1 步:真实鼠标点地图上的监测点标记 ── const marker = await stableMarker('MP-001') check('U1-monitoring-marker-projectable', Boolean(marker && Number.isFinite(marker.x) && Number.isFinite(marker.y)), marker ? `标记 ${marker.code} 窗口坐标 ${marker.x.toFixed(1)},${marker.y.toFixed(1)}` : '读不到可点的监测点标记') const locateMarker = () => page.evaluate(() => { const dev = window.__cockpitMap const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : [] return all.find((item) => item.visible && item.code === 'MP-001') || null }) const readDetail = () => page.evaluate(() => { const card = document.querySelector('[data-point-detail]') const aside = document.querySelector('.cockpit-shell__detail') return { detailLevel: card ? card.dataset.detailLevel : '', pointCode: card ? card.dataset.pointCode : '', asidePresent: !!aside, rows: [...document.querySelectorAll('[data-device-layer-row]')].map((n) => n.dataset.deviceLayerCode), } }) let opened = await readDetail() if (marker && !(opened.detailLevel === 'point' && opened.pointCode === 'MP-001')) { await clickUntil(locateMarker, async () => { const now = await readDetail() return now.detailLevel === 'point' && now.pointCode === 'MP-001' }) opened = await readDetail() } check('U2-real-marker-click-opens-point-detail', opened.detailLevel === 'point' && opened.pointCode === 'MP-001' && opened.asidePresent, `真实点标记后 detailLevel=${opened.detailLevel} pointCode=${opened.pointCode} aside=${opened.asidePresent} 清单=${opened.rows.join('/')}`) // 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态,不改生产行为)。 await page.evaluate(() => { const detail = document.querySelector('.cockpit-shell__detail') if (detail) { detail.style.pointerEvents = 'none' detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' }) } }) await waitSettled() const read = () => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const dev = window.__cockpitMap const devices = dev && dev.getAssociatedDevices ? dev.getAssociatedDevices() : { entities: [], onMap: 0 } return { overlap: dev && dev.getOverlapState ? dev.getOverlapState() : null, targets: dev && dev.getOverlapTargets ? dev.getOverlapTargets() : [], entities: devices.entities || [], onMap: devices.onMap, detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '', devicePicked: host && host.dataset.mapDevicePicked, panelSelection: host && host.dataset.mapPanelSelection, rows: [...document.querySelectorAll('[data-device-layer-row]')].length, } }) const before = await read() check('U3-overlap-aggregate-present', before.overlap && before.overlap.count === EXPECT.k && before.entities.some((e) => e.overlapAggregate) && before.rows === EXPECT.k, `K=${before.overlap && before.overlap.count}(期望 ${EXPECT.k})聚合实体=${before.entities.filter((e) => e.overlapAggregate).length} 清单=${before.rows} 行`) // ── 第 2 步:真实鼠标 hover + click 聚合符号 → 展开 ── const aggregateTarget = before.targets.find((t) => t.aggregate && t.visible) if (aggregateTarget) { await page.mouse.move(aggregateTarget.x, aggregateTarget.y) await sleep(350) const preview = await read() check('U4-real-hover-preview', preview.overlap && preview.overlap.preview === true && preview.overlap.expanded === false, `hover 后 preview=${preview.overlap && preview.overlap.preview} expanded=${preview.overlap && preview.overlap.expanded}`) await page.mouse.click(aggregateTarget.x, aggregateTarget.y) await sleep(600) } const expanded = await read() const visibleDevices = expanded.entities.filter((e) => e.deviceCode && e.visible) check('U5-real-click-expands-fan', expanded.overlap && expanded.overlap.state === EXPECT.state && visibleDevices.length === EXPECT.fanVisible, `state=${expanded.overlap && expanded.overlap.state}(期望 ${EXPECT.state})本屏设备=${visibleDevices.length}(期望 ${EXPECT.fanVisible}):${visibleDevices.map((e) => e.deviceCode).join('/')}`) // ── 第 3 步:真实鼠标点展开后的设备点 → 选中并进设备级 ── const deviceTarget = expanded.targets.find((t) => t.deviceCode === EXPECT.firstDevice && t.visible) if (deviceTarget) { await page.mouse.move(1200, 500) await sleep(150) await page.mouse.click(deviceTarget.x, deviceTarget.y) await sleep(1200) } const picked = await read() check('U6-real-click-fan-device-selects', Boolean(deviceTarget) && picked.devicePicked === EXPECT.firstDevice && picked.panelSelection === 'equipment', `设备目标=${Boolean(deviceTarget)}(${EXPECT.firstDevice}) picked=${picked.devicePicked} panel=${picked.panelSelection} detail=${picked.detailLevel}`) check('U7-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error') await page.close() await browser.close() const failed = results.filter((r) => !r.pass) console.log('') console.log(`T45 真实用户路径走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { for (const item of failed) console.log(` · ${item.id}`); process.exit(1) } console.log('T45 真实用户路径走查全部通过。')