Explorar el Código

票45主场景示例:MP-001=K2重叠、MP-002=K11降级,默认进入即可见

Kazerin hace 1 semana
padre
commit
7675a366e0

+ 23 - 1
.scratch/cockpit-shell-panel-layout/issues/45-overlapping-device-points.md

@@ -57,9 +57,31 @@ Labels: ready-for-agent
   EQ-001 ALARM + EQ-002 NORMAL,覆盖 K=2 正常重叠与预警模式过滤);
   追加 `device-layer-overlap-degraded` 样例(**11 台设备同坐标**、状态混合 3 ALARM / 3 WARNING / 5 NORMAL,
   K=11 > maxFan 10 ⇒ 降级形态「聚合呈现 11 台(本屏显示 10 台 / 其余见清单)」,真机验证通过)。
+- **主场景示例(维护者 2026-09-30 要求)**:两个示例**并入默认场景**,进入系统即可见,无需注入:
+  · `MP-001` 城南测试监测点 = **K=2 正常重叠**;· `MP-002` 太常测试监测点 = **K=11 降级**。
+  两份样例由 `overlapPointDetail()` / `degradedOverlapPointDetail()` **唯一实现**,主场景与专用场景共用
+  (避免同一份样例登记两处而漂移)。其余点位仍走默认现网边界(设备 `UNVERIFIED_COORDINATE/renderable=false`)。
+- `mocks/index.js`:人工测试通道 **`?scenario=xxx`**(Console 里设全局变量刷新即丢,
+  地址栏才是人工测试稳定入口;两者并存时 URL 优先)。
 - 证据:`verify/t45-overlap-devices.mjs`(**18/18**)+ 变异 **6/6 全打红** +
   `verify/t45-overlap-walkthrough.mjs`(**16/16**,含 W15 预警模式状态过滤、W14 四既有字段、
-  W16 无页面错误)。
+  W16 无页面错误)+ `verify/t45-real-user-path-walkthrough.mjs`(**7/7**,全程真实鼠标点地图标记:
+  进入系统 → 点开监测点 → 展开设备点 → 逐台拾取;K=2 与 K=11 两场景各跑通)+
+  `verify/t45-main-scene-samples-walkthrough.mjs`(**5/5**,**不注入任何场景**验证主场景
+  MP-001=K2 / MP-002=K11 降级直接可见)。
+- `verify/t05-adapter.mjs`:C1 跟随主场景新样例(MP-002 → 11 台),并补 `C1b` 钉住 MP-001 的 K=2 样例。
+
+### 本轮真机走查抓到的两个**整站级**缺陷(都不是本票逻辑,但会让人工走查无法进行)
+
+1. **页面整站挂载失败**:`domain/filterOptions.js` 里 `POINT_STATUS_FILTER_KEYS` 在 `POINT_STATUS_KEYS`
+   **之前**引用它 —— `const` 暂时性死区导致模块加载即抛 `ReferenceError`,驾驶舱根本打不开
+   (并发会话在途改动引入;已按声明顺序修复)。
+2. **壳层三处未定义引用**:`CockpitShell.vue` 把 `mockReady` 改名 `lifecycleReady` 时漏改 3 处
+   `mockReady.value`,运行期 `ReferenceError` 打断初始化与筛选跟随(同样是在途改动;已按注释记载的新名补齐)。
+
+另修一处**只读投影**缺陷(本票自己的):点位标记是 `CLAMP_TO_GROUND` 贴地形渲染,
+`readPointMarkerTargets()` 原先按椭球零高度投影,屏幕坐标与画面标记差出一段地形高度,
+真实鼠标按该坐标点**打不中**(走查抓到)。已改为与渲染同口径(先取地形高度再投影)。
 
 ### 关键取舍(都来自已 resolved 的规格 17 / 决策票 11,不在本票发明)
 

+ 7 - 1
.scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs

@@ -320,8 +320,14 @@ const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit
     check('C1-aggregate-substates', aggregate.data.sections.metrics.state === SECTION_STATE.SUCCESS && aggregate.data.sections.alarms.data[0].code === 'ALM-20260902-001')
 
     const point = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig })
-    check('C1-point-success', point.state === SECTION_STATE.SUCCESS && point.data.code === 'MP-002' && point.data.devices.length === 2)
+    // 主场景样例(维护者要求):MP-002 = **K=11 降级**(11 台同坐标设备,超过 maxFan 默认 10)。
+    check('C1-point-success', point.state === SECTION_STATE.SUCCESS && point.data.code === 'MP-002' && point.data.devices.length === 11)
     check('C1-point-path-param', point.state === SECTION_STATE.SUCCESS, '模板路径 /monitoring-points/* 命中(带路径参数的接口也要能 mock)')
+    // 主场景样例:MP-001 = **K=2 正常重叠**(两台同坐标设备,可逐台展开)。
+    const overlapPoint = await fetchMonitoringPointDetail('MP-001', { config: builtinConfig })
+    check('C1b-main-scene-overlap-sample',
+        overlapPoint.state === SECTION_STATE.SUCCESS && overlapPoint.data.code === 'MP-001' && overlapPoint.data.devices.length === 2,
+        `主场景 MP-001 返回 ${overlapPoint.data && overlapPoint.data.devices ? overlapPoint.data.devices.length : '?'} 台(期望 K=2 重叠示例)`)
 
     const events = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig })
     check('C1-events-success', events.state === SECTION_STATE.SUCCESS && allEvents(events.data).length === 2)

+ 175 - 0
.scratch/cockpit-shell-panel-layout/verify/t45-main-scene-samples-walkthrough.mjs

@@ -0,0 +1,175 @@
+/**
+ * T45 主场景示例走查:**不注入任何场景**(默认 `success`),验证维护者要求的两条示例直接可见:
+ *   · MP-001 城南测试监测点 —— K=2 正常重叠(聚合符号 K=2,可逐台展开);
+ *   · MP-002 太常测试监测点 —— K=11 降级(本屏 10 台 / 其余见清单)。
+ *
+ * 全程真实鼠标点地图标记(与用户路径一致);坐标读取用只读 `getPointMarkerTargets()`,
+ * 并在投影点附近做小网格扫描点击(贴地形标记会随地形细化抖动几像素,真人点不中也会再点)。
+ */
+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'
+// 主场景:**不带** scenario 查询参数。
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+
+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()}`) })
+
+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(() => {})
+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 s = dev && dev.mapSummary ? dev.mapSummary.getState() : null
+    return !s || ['complete', 'empty', 'failed', 'expired'].includes(s.phase)
+}, { timeout: 20000 }).catch(() => {})
+await page.waitForFunction(() => {
+    const dev = window.__cockpitMap
+    const s = dev && dev.getCameraState ? dev.getCameraState() : null
+    return !s || s.settled === true
+}, { timeout: 15000 }).catch(() => {})
+await sleep(1500)
+
+const locate = (code) => 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)
+
+const readOverlap = () => 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,
+        detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
+        pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '',
+        rows: [...document.querySelectorAll('[data-device-layer-row]')].map((n) => n.dataset.deviceLayerCode),
+        aggregateEntities: (devices.entities || []).filter((e) => e.overlapAggregate).length,
+        mapOverlapState: host && host.dataset.mapOverlapState,
+        mapOverlapCount: host && host.dataset.mapOverlapCount,
+    }
+})
+
+const OFFSETS = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6]]
+
+/** 真实鼠标点某个点位标记,直到点位详情打开(或重试用尽)。 */
+async function openPoint(code) {
+    for (let round = 0; round < 5; round += 1) {
+        const marker = await locate(code)
+        if (marker) {
+            for (const [dx, dy] of OFFSETS) {
+                await page.mouse.move(1200, 300)
+                await sleep(70)
+                await page.mouse.move(marker.x + dx, marker.y + dy)
+                await sleep(140)
+                await page.mouse.click(marker.x + dx, marker.y + dy)
+                await sleep(800)
+                const now = await readOverlap()
+                if (now.detailLevel === 'point' && now.pointCode === code) return now
+            }
+        }
+        await sleep(400)
+    }
+    return readOverlap()
+}
+
+// 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态)。
+async function passthroughDetail() {
+    await page.evaluate(() => {
+        const detail = document.querySelector('.cockpit-shell__detail')
+        if (detail) {
+            detail.style.pointerEvents = 'none'
+            detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' })
+        }
+    })
+}
+
+/** 真实点击顶栏「归位」,等相机收敛回广视角(相机飞近会把另一个标记挤出画布)。 */
+async function returnHome() {
+    await page.click('.cockpit-shell__home').catch(() => {})
+    await page.waitForFunction(() => {
+        const dev = window.__cockpitMap
+        const s = dev && dev.getCameraState ? dev.getCameraState() : null
+        return !s || s.settled === true
+    }, { timeout: 20000 }).catch(() => {})
+    await sleep(1200)
+}
+
+// ── 示例 1:MP-001 = K=2 正常重叠 ──
+const first = await openPoint('MP-001')
+await passthroughDetail()
+check('M1-main-scene-MP001-opens',
+    first.detailLevel === 'point' && first.pointCode === 'MP-001',
+    `真实点 MP-001 后 detailLevel=${first.detailLevel} pointCode=${first.pointCode}`)
+check('M2-main-scene-MP001-K2',
+    first.overlap && first.overlap.count === 2 && first.overlap.state === 'collapsed' &&
+    first.aggregateEntities === 1 && first.rows.length === 2 && first.mapOverlapCount === '2',
+    `MP-001:K=${first.overlap && first.overlap.count} state=${first.overlap && first.overlap.state} 聚合实体=${first.aggregateEntities} 清单=${first.rows.length} 行 data-count=${first.mapOverlapCount}`)
+
+// ── 示例 2:MP-002 = K=11 降级 ──
+// 先归位(真实点顶栏按钮):点开 MP-001 后相机会飞近,MP-002 会被挤出画布。
+await returnHome()
+const markerDiag = await page.evaluate(() => {
+    const dev = window.__cockpitMap
+    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+    const rect = host ? host.getBoundingClientRect() : null
+    const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
+    const cam = dev && dev.getCameraState ? dev.getCameraState() : null
+    return {
+        host: rect ? { left: Math.round(rect.left), top: Math.round(rect.top), right: Math.round(rect.right), bottom: Math.round(rect.bottom) } : null,
+        camera: cam ? { settled: cam.settled, hpr: cam.hpr } : null,
+        targets: all.map((t) => ({ code: t.code, x: Math.round(t.x), y: Math.round(t.y), visible: t.visible })),
+    }
+})
+console.log(`归位后标记坐标(证据):${JSON.stringify(markerDiag)}`)
+const second = await openPoint('MP-002')
+await passthroughDetail()
+check('M3-main-scene-MP002-opens',
+    second.detailLevel === 'point' && second.pointCode === 'MP-002',
+    `真实点 MP-002 后 detailLevel=${second.detailLevel} pointCode=${second.pointCode}`)
+check('M4-main-scene-MP002-K11-degraded',
+    second.overlap && second.overlap.count === 11 && second.overlap.state === 'degraded' &&
+    second.aggregateEntities === 1 && second.rows.length === 11 && second.mapOverlapCount === '11' &&
+    second.mapOverlapState === 'degraded',
+    `MP-002:K=${second.overlap && second.overlap.count} state=${second.overlap && second.overlap.state} 聚合实体=${second.aggregateEntities} 清单=${second.rows.length} 行 data-state=${second.mapOverlapState} data-count=${second.mapOverlapCount}`)
+
+check('M5-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 主场景示例走查全部通过。')

+ 264 - 0
.scratch/cockpit-shell-panel-layout/verify/t45-real-user-path-walkthrough.mjs

@@ -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 真实用户路径走查全部通过。')

+ 82 - 49
src/modules/cockpit/mocks/contractMocks.js

@@ -53,7 +53,8 @@ const MAP_POINTS = [
         pointName: '城南测试监测点',
         pointStatus: 'NORMAL',
         statusReasons: [],
-        deviceCodes: ['EQ-001'],
+        // 维护者要求的主场景示例之一:**K=2 正常重叠**(两台设备同坐标,可逐台展开)。
+        deviceCodes: DEVICE_LAYER_CODES.slice(),
         // 票 18:`map-snapshot` 真的收 industry/region/deviceType 三个筛选入参(票 25 第 3 节的
         // 冻结路径),mock 必须照此**真的过滤** —— 否则「筛选下发到地图」的断言会变成假绿
         // (请求串里有参数、但样例永远回同一份数据)。这两个字段域对象不读,只服务过滤。
@@ -70,7 +71,8 @@ const MAP_POINTS = [
         pointName: '太常测试监测点',
         pointStatus: 'ALARM',
         statusReasons: ['EQ-ASSOC-001 报警'],
-        deviceCodes: ['EQ-002'],
+        // 维护者要求的主场景示例之一:**K=11 降级**(超过 `map.overlap.maxFan` 默认 10)。
+        deviceCodes: OVERLAP_DEGRADED_CODES.slice(),
         industry: 'GAS',
         region: '城区A',
         longitude: 110.36299369455823,
@@ -435,7 +437,63 @@ function mapFilterEcho(filters, points, equipment) {
  * 界面若自作主张排序,真机走查里那条「卡片顺序 = 后端顺序」就会当场露馅。
  * 默认样例一个字节未动(票 05 的断言依赖它)。
  */
-function pointDetailPayload(scenario) {
+/**
+ * 票 45:**K>10 降级载荷**(11 台设备同坐标、状态混合)。
+ *
+ * 主场景(MP-002)与专用场景 `device-layer-overlap-degraded` **共用这一份** ——
+ * 两处各写一份必然漂移(本仓反复栽在"同一份样例登记两处"上)。
+ */
+function degradedOverlapPointDetail({ code, name }) {
+    const statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL']
+    const devices = OVERLAP_DEGRADED_CODES.map((code_, index) => {
+        const deviceStatus = statuses[index]
+        return {
+            deviceCode: code_,
+            deviceName: `重叠降级设备-${index + 1}`,
+            deviceTypeName: ['压力计', '流量计', '液位计'][index % 3],
+            deviceStatus,
+            statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[deviceStatus],
+            statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
+            statusTime: '2026-09-02T09:59:00+08:00',
+            ...deviceCoordFields(OVERLAP_DEGRADED_COORDS, index),
+        }
+    })
+    return {
+        requestId: 'mock-point-detail',
+        apiVersion: 'v1',
+        readOnly: true,
+        monitoringPointCode: code,
+        monitoringPointName: name,
+        pointStatus: 'ALARM',
+        statusLabel: '报警',
+        statusReasons: [{ status: 'ALARM', deviceCode: OVERLAP_DEGRADED_CODES[0], reason: '压力超高' }],
+        deviceStatistics: {
+            totalDeviceCount: devices.length,
+            alarmDeviceCount: 3,
+            warningDeviceCount: 3,
+            workOrderDeviceCount: 0,
+            normalDeviceCount: 5,
+            offlineDeviceCount: 0,
+        },
+        devices,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+        statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
+            status: item,
+            label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
+            priority: index + 1,
+        })),
+    }
+}
+
+/**
+ * 票 45:**K=2 正常重叠载荷**(两台设备同坐标,可逐台展开)。
+ * 主场景(MP-001)与专用场景 `device-layer-overlap` 共用。
+ */
+function overlapPointDetail({ code, name, status = 'NORMAL' }) {
+    return pointDetail({ code, name, status, deviceCoord: OVERLAP_DEVICE_LAYER_COORDS })
+}
+
+function pointDetailPayload(scenario, requestedCode = '') {
     if (scenario === 'empty') {
         return {
             ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }),
@@ -468,12 +526,7 @@ function pointDetailPayload(scenario) {
 
     /** 票 45:两台设备拥有**各自真实字段**,但坐标恰好重叠;用于测试纯渲染层聚合/展开。 */
     if (scenario === 'device-layer-overlap') {
-        return pointDetail({
-            code: DEVICE_LAYER_POINT,
-            name: '城南测试监测点',
-            status: 'NORMAL',
-            deviceCoord: OVERLAP_DEVICE_LAYER_COORDS,
-        })
+        return overlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' })
     }
 
     /**
@@ -482,45 +535,7 @@ function pointDetailPayload(scenario) {
      * 「K 台设备(本屏显示 10 台 / 其余见清单)」。聚合符号显示最高优先级状态(ALARM)。
      */
     if (scenario === 'device-layer-overlap-degraded') {
-        const statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL']
-        const devices = OVERLAP_DEGRADED_CODES.map((code, index) => {
-            const deviceStatus = statuses[index]
-            return {
-                deviceCode: code,
-                deviceName: `重叠降级设备-${index + 1}`,
-                deviceTypeName: ['压力计', '流量计', '液位计'][index % 3],
-                deviceStatus,
-                statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[deviceStatus],
-                statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
-                statusTime: '2026-09-02T09:59:00+08:00',
-                ...deviceCoordFields(OVERLAP_DEGRADED_COORDS, index),
-            }
-        })
-        return {
-            requestId: 'mock-point-detail',
-            apiVersion: 'v1',
-            readOnly: true,
-            monitoringPointCode: DEVICE_LAYER_POINT,
-            monitoringPointName: '城南测试监测点',
-            pointStatus: 'ALARM',
-            statusLabel: '报警',
-            statusReasons: [{ status: 'ALARM', deviceCode: OVERLAP_DEGRADED_CODES[0], reason: '压力超高' }],
-            deviceStatistics: {
-                totalDeviceCount: devices.length,
-                alarmDeviceCount: 3,
-                warningDeviceCount: 3,
-                workOrderDeviceCount: 0,
-                normalDeviceCount: 5,
-                offlineDeviceCount: 0,
-            },
-            devices,
-            coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
-            statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
-                status: item,
-                label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
-                priority: index + 1,
-            })),
-        }
+        return degradedOverlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' })
     }
 
     if (scenario === 'point-detail-order') {
@@ -537,6 +552,24 @@ function pointDetailPayload(scenario) {
         }
     }
 
+    /**
+     * **主场景**(默认 `success`)按请求的点位编码分发两个重叠示例 ——
+     * 维护者要求「进入系统即可见 K=2 与 K=11」,不必再注入场景:
+     *   · MP-001 城南测试监测点 —— K=2 正常重叠(两台设备同坐标,可逐台展开);
+     *   · MP-002 太常测试监测点 —— K=11 降级(本屏 10 台 / 其余见清单)。
+     * 其余点位仍走默认现网边界(设备 `UNVERIFIED_COORDINATE/renderable=false`,不上图仅清单),
+     * 那条边界是票 42 照抄的正式行为,不被示例覆盖。
+     */
+    if (scenario === 'success') {
+        if (requestedCode === 'MP-001') {
+            return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' })
+        }
+        if (requestedCode === 'MP-002') {
+            // 主场景的 MP-002 是 ALARM 点位(示例用于展示降级聚合的最高优先级状态)。
+            return degradedOverlapPointDetail({ code: 'MP-002', name: '太常测试监测点' })
+        }
+    }
+
     return pointDetail()
 }
 
@@ -2153,7 +2186,7 @@ function overviewWarningSettingsPayload(scenario, query) {
 const PAYLOADS = {
     // 票 08:地图摘要按**真实分页语义**给(分页窗口横跨监测点与未关联设备、第 2 页起要求快照一致)。
     '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot),
-    '/monitoring-points/*': (s) => pointDetailPayload(s),
+    '/monitoring-points/*': (s, requested) => pointDetailPayload(s, requested),
     '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested),
     '/equipments/*/events/current': (s, requested) => eventRead(s, requested),
     '/equipments/*/events/history': (s, requested) => eventRead(s, requested),