/** T45 真机走查:重叠设备聚合与展开(票45 / 规格17)。 */ import { cdpLaunch } from './cdp-driver.mjs' 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 SCENARIO = 'device-layer-overlap' const results = [] const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) function check(id, pass, evidence) { results.push({ id, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence || ''}`) } 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.evaluateOnNewDocument((name) => { window.__COCKPIT_MOCK_SCENARIO__ = name }, SCENARIO) 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 sleep(1200) // 推进地图摘要到 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 page.evaluate(() => window.__cockpitMap && window.__cockpitMap.selectMonitoringPoint && window.__cockpitMap.selectMonitoringPoint('MP-001')) await page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 20000 }).catch(() => {}) await sleep(2500) 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: [], overlap: null, onMap: 0 } return { overlap: dev && dev.getOverlapState ? dev.getOverlapState() : null, targets: dev && dev.getOverlapTargets ? dev.getOverlapTargets() : [], entities: devices.entities || [], onMap: devices.onMap, mapPicked: host && host.dataset.mapPicked, devicePicked: host && host.dataset.mapDevicePicked, panelSelection: host && host.dataset.mapPanelSelection, deviceHovered: host && host.dataset.mapDeviceHovered, correspondence: dev && dev.getDeviceCorrespondence ? dev.getDeviceCorrespondence() : null, detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '', rows: [...document.querySelectorAll('[data-device-layer-row]')].length, } }) const collapsed = await read() const aggregate = collapsed.entities.filter((e) => e.overlapAggregate) const hiddenDevices = collapsed.entities.filter((e) => e.deviceCode && e.visible === false) check('W1-collapsed-has-aggregate', collapsed.overlap && collapsed.overlap.state === 'collapsed' && collapsed.overlap.count === 2 && aggregate.length === 1, `state=${collapsed.overlap && collapsed.overlap.state} K=${collapsed.overlap && collapsed.overlap.count} 聚合实体=${aggregate.length}`) check('W2-aggregate-has-no-device-identity', aggregate.length === 1 && !aggregate[0].deviceCode && aggregate[0].overlapCount === 2, `聚合实体 deviceCode=${aggregate[0] && aggregate[0].deviceCode || '(空)'} count=${aggregate[0] && aggregate[0].overlapCount}`) check('W3-collapsed-real-devices-hidden', hiddenDevices.length === 2 && collapsed.onMap === 0, `隐藏逐台设备=${hiddenDevices.length} onMap=${collapsed.onMap}`) check('W4-list-keeps-all-devices', collapsed.rows === 2, `清单行数=${collapsed.rows}`) const aggregateTarget = collapsed.targets.find((target) => target.aggregate && target.visible) check('W5-aggregate-projects-on-screen', Boolean(aggregateTarget && Number.isFinite(aggregateTarget.x) && Number.isFinite(aggregateTarget.y)), aggregateTarget ? `窗口坐标 ${aggregateTarget.x.toFixed(1)},${aggregateTarget.y.toFixed(1)}` : '聚合实体未投影到视窗') if (aggregateTarget) { await page.evaluate(() => { const detail = document.querySelector('.cockpit-shell__detail') if (detail) { detail.style.pointerEvents = 'none' detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' }) } }) // 真实鼠标 hover:瞬态预览,离开即回折叠(不写选中态)。 await page.mouse.move(aggregateTarget.x, aggregateTarget.y) await sleep(350) const preview = await read() check('W6-real-hover-preview', preview.overlap && preview.overlap.preview === true && preview.overlap.expanded === false, `真实pointermove后 preview=${preview.overlap && preview.overlap.preview} expanded=${preview.overlap && preview.overlap.expanded}`) await page.mouse.move(1200, 500) await sleep(100) await page.mouse.move(1500, 750) await sleep(350) const left = await read() check('W7-real-mouseleave-collapses', left.overlap && left.overlap.state === 'collapsed' && left.overlap.preview === false, `离开后 state=${left.overlap && left.overlap.state} preview=${left.overlap && left.overlap.preview}`) // 真实鼠标 click 聚合符号:粘性展开。 const targetAgain = left.targets.find((target) => target.aggregate && target.visible) || aggregateTarget await page.mouse.move(1200, 500) await sleep(100) await page.mouse.click(targetAgain.x, targetAgain.y) await sleep(500) const expanded = await read() const visibleDevices = expanded.entities.filter((e) => e.deviceCode && e.visible) const expandedAggregate = expanded.entities.filter((e) => e.overlapAggregate && e.visible) check('W8-real-click-sticky-expands', expanded.overlap && expanded.overlap.state === 'expanded' && expanded.overlap.expanded === true, `真实click后 state=${expanded.overlap && expanded.overlap.state} expanded=${expanded.overlap && expanded.overlap.expanded}`) check('W9-expanded-shows-device-identities', visibleDevices.length === 2 && visibleDevices.every((e) => e.deviceCode) && expandedAggregate.length === 0, `可见设备=${visibleDevices.map((e) => e.deviceCode).join('/')} 聚合可见=${expandedAggregate.length}`) check('W10-expanded-screen-offsets-preserve-space', visibleDevices.every((e) => e.pixelOffset && (e.pixelOffset.x !== 0 || e.pixelOffset.y !== 0)), `pixelOffset=${visibleDevices.map((e) => `${e.deviceCode}:${e.pixelOffset && e.pixelOffset.x},${e.pixelOffset && e.pixelOffset.y}`).join(' ')}`) // 真实鼠标 click 展开子设备:普通拾取返回真实设备身份,设备优先拾取生效。 const deviceTarget = expanded.targets.find((target) => target.deviceCode === 'EQ-001' && target.visible) if (deviceTarget) { await page.mouse.move(1200, 500) await sleep(150) await page.mouse.click(deviceTarget.x, deviceTarget.y) } await sleep(600) const picked = await read() check('W11-real-pick-expanded-device', Boolean(deviceTarget) && picked.devicePicked === 'EQ-001' && picked.panelSelection === 'equipment', `设备目标=${Boolean(deviceTarget)} picked=${picked.devicePicked} panel=${picked.panelSelection}`) // 面板返回点位结束展开(走 store 层级回退入口,与票 43 同一用户路径)。 await page.evaluate(() => window.__cockpitMap && window.__cockpitMap.backToPointLevel && window.__cockpitMap.backToPointLevel()) await sleep(450) const backed = await read() check('W12-back-to-point-collapses', backed.overlap && backed.overlap.state === 'collapsed' && !backed.overlap.expanded, `返回点位后 state=${backed.overlap && backed.overlap.state}`) // 重新真实点击聚合后点空白,展开也应收起。 const aggregateAfterBack = backed.targets.find((target) => target.aggregate && target.visible) if (aggregateAfterBack) { await page.mouse.click(aggregateAfterBack.x, aggregateAfterBack.y) await sleep(300) // 纯只读探针:确认空白点落在 canvas 上(不被面板/浮层覆盖),不触发任何拾取。 const blankProbe = await page.evaluate(() => { const el = document.elementFromPoint(700, 800) const host = document.querySelector('.cockpit-shell__map .map-canvas') window.__t45BlankPointer = { down: 0, up: 0 } if (host) { host.addEventListener('pointerdown', () => { window.__t45BlankPointer.down += 1 }) host.addEventListener('pointerup', () => { window.__t45BlankPointer.up += 1 }) } return { element: el ? `${el.tagName}.${String(el.className).replace(/ /g, '.')}` : '' } }) await page.mouse.move(700, 800) await sleep(120) await page.mouse.click(700, 800) await sleep(400) const blankReachedCanvas = await page.evaluate(() => window.__t45BlankPointer.down === 1 && window.__t45BlankPointer.up === 1) const blank = await read() check('W13-blank-click-collapses', Boolean(aggregateAfterBack) && blankReachedCanvas === true && blank.overlap && blank.overlap.state === 'collapsed' && !blank.overlap.expanded, `空白click后 state=${blank.overlap && blank.overlap.state}(canvas命中=${blankReachedCanvas})`) } // 预警处置模式:分项切换按票46语义会清空选择;重新选择MP-001后再验证状态过滤。 await page.evaluate(() => { const button = [...document.querySelectorAll('.cockpit-shell__mode')][3] if (button) button.click() }) await sleep(900) await page.evaluate(() => window.__cockpitMap && window.__cockpitMap.selectMonitoringPoint && window.__cockpitMap.selectMonitoringPoint('MP-001')) await page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 15000 }).catch(() => {}) await sleep(900) const warning = await read() const warningDevices = warning.entities.filter((e) => e.deviceCode && e.visible).map((e) => e.deviceCode) const warningAggregates = warning.entities.filter((e) => e.overlapAggregate && e.visible) check('W15-warning-mode-filters-device-status', !warningDevices.includes('EQ-002') && (warningDevices.includes('EQ-001') || warningAggregates.length === 1), `预警模式可见设备=${warningDevices.join('/') || '无'} 聚合=${warningAggregates.length} K=${warning.overlap && warning.overlap.count}`) } const finalFields = await read() const legacyFields = [finalFields.mapPicked, finalFields.devicePicked, finalFields.panelSelection, finalFields.deviceHovered] check('W14-existing-selection-fields-preserved', legacyFields.every((value) => value === undefined || typeof value === 'string'), `mapPicked=${finalFields.mapPicked || '(未初始化)'} devicePicked=${finalFields.devicePicked || '(未初始化)'} panel=${finalFields.panelSelection || '(未初始化)'} hovered=${finalFields.deviceHovered || '(未初始化)'}`) check('W16-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 真机走查全部通过。')