| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- /**
- * 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)
|