|
|
@@ -0,0 +1,264 @@
|
|
|
+/**
|
|
|
+ * 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<boolean>} 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 真实用户路径走查全部通过。')
|