| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- /**
- * T41 真机走查:监测点定位与面板锚定接入(票 41)。
- *
- * 逻辑闸门(t41-point-locate-anchor.mjs,37/37 + 变异 15/15)已经把定位档位解析、
- * 竞态作废判据、锚定四向布局、离屏判据与统一入口的接线测透了;这里只补**只有在真机才成立**的部分:
- * 1. 点地图上的监测点 → 相机**真的**飞到该点位(`settled` + hpr = 配置点位档,range≈500);
- * 2. 详情面板**真的贴着**该标记(面板矩形与标记投影的几何关系),且不压住图标;
- * 3. 面板四向避让:标记贴到画布右/左/下/上边缘时,面板翻到对应侧;
- * 4. 标记完全离开画面 → 面板隐藏 + 顶栏出现暂隐入口(带原因与关闭);回到视野自动恢复;
- * 5. 关闭详情 → 面板消失而**镜头不回退**(ADR 0008);
- * 6. 台账卡片与地图点选走**同一入口**(都触发点位级定位)——票 41 改写了票 18 的旧语义;
- * 7. 无地图位置(后端 `renderable=false`)→ 固定详情 + 非空原因,且**不创建锚点**;
- * 8. 全程无页面级报错。
- *
- * 运行(需先起 `yarn dev`;`AGENTS.md` 要求不自动跑构建/调试命令,故由维护者按需执行):
- * node .scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor-walkthrough.mjs
- * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
- *
- * 两个沿用票 10 的真机坑(换成"直接点屏幕中心"必然假失败):
- * · 相机落位是**阻尼飞行**,必须等 `shownPivot` 贴到目标再点;
- * · 贴地标记与"高度 0 的中心"之间有地形高程差 —— 先用引擎拾取找到标记真实屏幕坐标再真点。
- */
- 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'
- /** 与 mock 保持一致的测试点位(走查断言点的是"哪一个点")。 */
- const MP_SOUTH = 'MP-001'
- const MP_TAICHANG = 'MP-002'
- /** 后端判不可上图的点位(`renderable:false`)—— 固定详情特例。 */
- const MP_BLOCKED = 'MP-003'
- const results = []
- /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
- 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-t41-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()}`)
- /**
- * 一次读全:地图宿主读数 + 相机状态 + 详情浮层与锚定的几何。
- *
- * 锚定断言全部基于**真实矩形**(`getBoundingClientRect`),不读组件私有变量:
- * 「面板是否贴着标记」这种空间关系只有按真实几何判才算证据。
- */
- const readState = (page) =>
- page.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- const canvas = document.querySelector('.cockpit-shell__map canvas')
- const card = document.querySelector('[data-point-detail]')
- const aside = document.querySelector('.cockpit-shell__detail')
- const lead = document.querySelector('[data-anchor-lead]')
- const hiddenChip = document.querySelector('[data-anchor-hidden]')
- const dev = window.__cockpitMap
- const camera = dev && dev.getCameraState ? dev.getCameraState() : null
- const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
- const rectOf = (el) => {
- if (!el) return null
- const r = el.getBoundingClientRect()
- return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
- }
- return {
- engine: (host && host.dataset.mapEngine) || '',
- summaryPhase: (host && host.dataset.mapSummary) || '',
- summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
- // 相机:`settled` 与 hpr 是定位验收的判据(规格:定位完成以 settled 和配置 hpr 为准)。
- camera: camera
- ? {
- pivot: camera.pivot ? { lon: camera.pivot.lon, lat: camera.pivot.lat, height: camera.pivot.height } : null,
- shownPivot: camera.shownPivot ? { lon: camera.shownPivot.lon, lat: camera.shownPivot.lat } : null,
- hpr: camera.hpr ? { heading: camera.hpr.heading, pitch: camera.hpr.pitch, range: camera.hpr.range } : null,
- settled: camera.settled,
- flying: camera.flying,
- outsideArea: camera.outsideArea,
- }
- : null,
- canvasRect: rectOf(canvas),
- asideRect: rectOf(aside),
- cardRect: rectOf(card),
- // 锚定状态:可见/侧向/编码,以及暂隐入口的读数(都是本票新增的可断言面)。
- anchorState: aside ? aside.dataset.anchorState : '',
- anchorSide: aside ? aside.dataset.anchorSide : '',
- anchorCode: aside ? aside.dataset.anchorCode : '',
- hasLead: !!lead,
- hiddenChip: hiddenChip
- ? { reason: hiddenChip.dataset.anchorReason || '', text: hiddenChip.textContent.trim() }
- : null,
- hasRelocate: !!(hiddenChip && hiddenChip.querySelector('[data-anchor-relocate]')),
- hasHiddenClose: !!(hiddenChip && hiddenChip.querySelector('[data-anchor-close]')),
- detailLevel: card ? card.dataset.detailLevel : '',
- pointCode: card ? card.dataset.pointCode : '',
- pointName: ((card && card.querySelector('[data-point-name]')) || {}).textContent
- ? card.querySelector('[data-point-name]').textContent.trim()
- : '',
- noLocation: ((card && card.querySelector('[data-point-no-location]')) || {}).textContent
- ? card.querySelector('[data-point-no-location]').textContent.trim()
- : '',
- cardHosts: document.querySelectorAll('[data-point-detail]').length,
- // 锚定面板的层级(规格 R-3:必须低于四个详情窗 —— 在**页面上下文**里读计算样式,
- // 不能在 Node 侧对 DOM 元素调 getComputedStyle)。
- anchorZIndex: aside ? Number(window.getComputedStyle(aside).zIndex) : null,
- planMarkers: markers
- ? markers.markers.map((m) => ({ code: m.code, kind: m.kind, longitude: m.longitude, latitude: m.latitude }))
- : [],
- markersOnMap: markers ? markers.onMap : -1,
- }
- })
- /** 打开页面并走真实进入引导(不注入配置:注入了家视角会退化成 (0,0))。 */
- async function openPage() {
- 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, { 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(1800)
- return { page, errors }
- }
- /** 点「加载下一页」直到翻完(标记要等分页到位才出现)。 */
- async function advanceUntilSettled(page, maxSteps = 12) {
- for (let step = 0; step < maxSteps; step += 1) {
- const state = await readState(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 readState(page)
- }
- /** 等相机真的停稳(`settled`)且中心贴到目标 —— 阻尼飞行途中点屏幕必然点空。 */
- async function waitForSettled(page, timeout = 25000) {
- await page
- .waitForFunction(
- () => {
- const dev = window.__cockpitMap
- const state = dev && dev.getCameraState ? dev.getCameraState() : null
- return !!state && state.settled === true && state.flying !== true
- },
- { timeout },
- )
- .catch(() => {})
- }
- /** 在画布上找一个真实标记的屏幕坐标(引擎拾取与真实点击同一个 scene.pick)。 */
- async function locateMarker(page, kind, code, radius = 400, stride = 8) {
- return page.evaluate(
- ([wantedKind, wantedCode, max, step]) => {
- const dev = window.__cockpitMap
- const canvas = document.querySelector('.cockpit-shell__map canvas')
- const center = dev.projectCameraPivot()
- if (!canvas) return { x: 0, y: 0, code: '' }
- const origin = center || { x: canvas.getBoundingClientRect().left + canvas.clientWidth / 2, y: canvas.getBoundingClientRect().top + canvas.clientHeight / 2 }
- for (let dy = -max; dy <= max; dy += step) {
- for (let dx = -max; dx <= max; dx += step) {
- const x = Math.round(origin.x + dx)
- const y = Math.round(origin.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 && (!wantedCode || hit.code === wantedCode)) {
- return { x, y, dx, dy, code: hit.code, kind: hit.kind }
- }
- }
- }
- return { x: 0, y: 0, dx: 0, dy: 0, code: '', kind: '' }
- },
- [kind, code, radius, stride],
- )
- }
- /** 关掉可能已打开的浮层,让后续真实点击成为唯一证据。 */
- async function closeCard(page) {
- if (await page.$('[data-detail-close]')) {
- await page.click('[data-detail-close]')
- await sleep(300)
- }
- }
- try {
- const { page, errors } = await openPage()
- const settled = await advanceUntilSettled(page)
- check('A1-engine-ready', ['ready', 'disabled'].includes(settled.engine), `engine=${settled.engine}`)
- check(
- 'A2-test-points-on-map',
- settled.planMarkers.some((m) => m.code === MP_SOUTH) && settled.planMarkers.some((m) => m.code === MP_TAICHANG),
- `地图计划里有点位:${settled.planMarkers.map((m) => m.code).join(',') || '(无)'}`,
- )
- // ================================================================ B 地图点选 → 点位级完整定位
- const targetMarker = settled.planMarkers.find((m) => m.code === MP_SOUTH) || settled.planMarkers[0]
- if (targetMarker) {
- // 先把镜头对到该点位附近,才能用真实鼠标点到它(否则它在县域视角外/被裁切)。
- await page.evaluate((code) => {
- const dev = window.__cockpitMap
- const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
- if (marker) dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
- }, targetMarker.code)
- await waitForSettled(page)
- const located = await locateMarker(page, 'monitoring-point', targetMarker.code)
- await closeCard(page)
- const beforeClick = await readState(page)
- if (located.code) {
- await page.mouse.click(located.x, located.y)
- await sleep(700)
- }
- await waitForSettled(page)
- const afterClick = await readState(page)
- check(
- 'B1-real-click-opens-detail',
- !!located.code && beforeClick.cardHosts === 0 && afterClick.cardHosts === 1 && afterClick.detailLevel === 'point',
- located.code
- ? `真实鼠标点 (${located.x},${located.y}) → 点位 ${afterClick.pointCode} · 层级 ${afterClick.detailLevel}`
- : '没找到可点的标记',
- )
- check(
- 'B2-located-to-point-level-range',
- !!afterClick.camera && Math.round(afterClick.camera.hpr.range) === 500,
- `定位后 range=${afterClick.camera ? Math.round(afterClick.camera.hpr.range) : 'n/a'} m(配置点位档缺省 500;旧语义会留在县域 12000)`,
- )
- check(
- 'B3-located-hpr-matches-preset',
- !!afterClick.camera && afterClick.camera.hpr.pitch === -55 && afterClick.camera.hpr.heading === 0,
- `hpr=${JSON.stringify(afterClick.camera && afterClick.camera.hpr)}(配置缺省 0/−55)`,
- )
- check(
- 'B4-settled-and-centered-on-the-point',
- !!afterClick.camera &&
- afterClick.camera.settled === true &&
- Math.abs(afterClick.camera.pivot.lon - targetMarker.longitude) < 1e-6 &&
- Math.abs(afterClick.camera.pivot.lat - targetMarker.latitude) < 1e-6,
- `settled=${afterClick.camera && afterClick.camera.settled} · 中心 ${afterClick.camera && afterClick.camera.pivot.lon},${afterClick.camera && afterClick.camera.pivot.lat}`,
- )
- check(
- 'B5-center-takes-terrain-height',
- !!afterClick.camera && Number.isFinite(afterClick.camera.pivot.height) && afterClick.camera.pivot.height !== 0,
- `中心高程 ${afterClick.camera && afterClick.camera.pivot.height} m(取实测地形,不是椭球 0)`,
- )
- // ============================================================ C 锚定跟随(真实几何)
- check(
- 'C1-panel-anchored-visible',
- afterClick.anchorState === 'visible' && !!afterClick.asideRect && afterClick.anchorCode === afterClick.pointCode,
- `锚定状态 ${afterClick.anchorState} · 侧向 ${afterClick.anchorSide} · 锚点编码 ${afterClick.anchorCode}`,
- )
- check(
- 'C2-panel-has-lead-line',
- afterClick.hasLead === true,
- `归属短线在(${afterClick.hasLead})`,
- )
- check(
- 'C3-panel-does-not-cover-icon',
- (() => {
- const anchor = afterClick.camera && afterClick.camera.pivot
- const card = afterClick.cardRect
- if (!anchor || !card) return false
- // 标记图标 18px、间距 12px:面板边缘到标记中心的距离必须 > 9+11(留 1px 浮点余量)。
- const cx = (card.left + card.right) / 2
- const cy = (card.top + card.bottom) / 2
- const gapX = Math.max(card.left - anchor.x, anchor.x - card.right, 0)
- const gapY = Math.max(card.top - anchor.y, anchor.y - card.bottom, 0)
- return Math.max(gapX, gapY) >= 20 || (cx !== anchor.x && cy !== anchor.y)
- })(),
- `面板 ${afterClick.anchorSide} 侧、尺寸 ${afterClick.cardRect ? Math.round(afterClick.cardRect.width) + '×' + Math.round(afterClick.cardRect.height) : 'n/a'}`,
- )
- check(
- 'C4-panel-is-lower-than-columns',
- afterClick.anchorZIndex !== null && afterClick.anchorZIndex < 2,
- `锚定面板 z-index=${afterClick.anchorZIndex}(< 2 = 低于 --z-columns,规格 R-3:允许被四个详情窗遮盖)`,
- )
- } else {
- check('B1-real-click-opens-detail', false, '地图上没有可点的监测点标记')
- }
- // ================================================================ D 关闭不回退镜头
- const beforeClose = await readState(page)
- if (beforeClose.cardHosts > 0) {
- await page.click('[data-detail-close]')
- await sleep(600)
- }
- const afterClose = await readState(page)
- check(
- 'D1-close-hides-panel',
- afterClose.cardHosts === 0,
- `关闭后浮层 ${afterClose.cardHosts} 个`,
- )
- check(
- 'D2-close-does-not-roll-back-camera',
- !!beforeClose.camera &&
- !!afterClose.camera &&
- Math.abs(afterClose.camera.hpr.range - beforeClose.camera.hpr.range) < 1 &&
- Math.abs(afterClose.camera.pivot.lon - beforeClose.camera.pivot.lon) < 1e-9,
- `关闭前 range=${beforeClose.camera && Math.round(beforeClose.camera.hpr.range)} → 关闭后 ${afterClose.camera && Math.round(afterClose.camera.hpr.range)}(ADR 0008:取消选中不回退)`,
- )
- // ================================================================ E 标记离屏 → 暂隐 + 恢复
- {
- // 打开详情后把镜头**拉远到全球**,标记必然离开画面 → 面板暂隐 + 顶栏入口出现。
- await page.evaluate((code) => {
- const dev = window.__cockpitMap
- const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
- if (marker) dev.selectMonitoringPoint ? dev.selectMonitoringPoint(code) : null
- }, MP_SOUTH)
- await sleep(800)
- const opened = await readState(page)
- await page.evaluate(() => {
- const dev = window.__cockpitMap
- if (dev.returnGlobal) dev.returnGlobal()
- })
- await sleep(2500)
- const offscreen = await readState(page)
- check(
- 'E1-detail-open-before-offscreen',
- opened.cardHosts === 1,
- `离屏前详情是打开的(${opened.cardHosts} 个浮层)`,
- )
- check(
- 'E2-offscreen-hides-panel',
- offscreen.anchorState === 'hidden' || (offscreen.asideRect && offscreen.asideRect.height < 1) || !offscreen.cardRect,
- `拉远后锚定状态 ${offscreen.anchorState}(面板不留在失去归属的位置)`,
- )
- check(
- 'E3-offscreen-keeps-selection-with-exit',
- !!offscreen.hiddenChip && !!offscreen.hiddenChip.reason && offscreen.hasHiddenClose,
- `顶栏暂隐入口:原因「${offscreen.hiddenChip && offscreen.hiddenChip.reason}」· 关闭入口 ${offscreen.hasHiddenClose}`,
- )
- // 回到点位 → 自动恢复(不重新点选)。
- await page.evaluate((code) => {
- const dev = window.__cockpitMap
- const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
- if (marker) dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
- }, MP_SOUTH)
- await sleep(2500)
- const restored = await readState(page)
- check(
- 'E4-restores-when-marker-returns',
- restored.cardHosts === 1 && restored.anchorState === 'visible',
- `回到视野后自动恢复:浮层 ${restored.cardHosts} 个 · 锚定 ${restored.anchorState}`,
- )
- await closeCard(page)
- }
- // ================================================================ F 无地图位置 → 固定详情
- {
- // 后端判 `renderable:false` 的点位:从台账卡片打开(地图上没有它的标记)。
- const opened = await page.evaluate(async (code) => {
- const dev = window.__cockpitMap
- // 台账卡片入口与地图点选共用统一入口(票 41 验收第 1 条)。
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- const before = document.querySelector('[data-point-detail]')
- if (dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
- await new Promise((r) => setTimeout(r, 900))
- const card = document.querySelector('[data-point-detail]')
- const aside = document.querySelector('.cockpit-shell__detail')
- return {
- hadCardBefore: !!before,
- pointCode: card ? card.dataset.pointCode : '',
- noLocation: card && card.querySelector('[data-point-no-location]') ? card.querySelector('[data-point-no-location]').textContent.trim() : '',
- anchorState: aside ? aside.dataset.anchorState : '',
- engine: host.dataset.mapEngine,
- }
- }, MP_BLOCKED)
- check(
- 'F1-blocked-point-opens-fixed-detail',
- opened.pointCode === MP_BLOCKED && opened.anchorState === 'fixed',
- `点位 ${opened.pointCode} · 锚定状态 ${opened.anchorState}(固定详情,不伪造锚点)`,
- )
- check(
- 'F2-fixed-detail-has-non-empty-reason',
- !!opened.noLocation && opened.noLocation.includes('无地图位置'),
- `固定详情注明原因:「${opened.noLocation}」`,
- )
- await closeCard(page)
- }
- // ================================================================ G 台账卡片与地图同一入口
- {
- const sameEntry = await page.evaluate(async () => {
- const dev = window.__cockpitMap
- const marker = dev.getPointMarkers().markers.find((m) => m.kind === 'monitoring-point')
- if (!marker) return { ok: false, reason: 'no-marker' }
- // 台账入口 → 统一入口 → 点位级定位(票 41 改写票 18 的"不动相机")。
- const before = dev.getCameraState()
- dev.selectMonitoringPoint(marker.code)
- await new Promise((r) => setTimeout(r, 2600))
- const after = dev.getCameraState()
- return {
- ok: true,
- code: marker.code,
- beforeRange: before ? before.hpr.range : null,
- afterRange: after ? after.hpr.range : null,
- settled: after ? after.settled : null,
- }
- })
- check(
- 'G1-ledger-entry-also-locates',
- sameEntry.ok && Math.round(sameEntry.afterRange) === 500,
- sameEntry.ok
- ? `统一入口把 range 从 ${Math.round(sameEntry.beforeRange)} 换成 ${Math.round(sameEntry.afterRange)} m(settled=${sameEntry.settled})`
- : '没有可用的监测点标记',
- )
- await closeCard(page)
- }
- // ================================================================ H 无页面级报错
- check('H1-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '零页面级报错')
- await page.close()
- } finally {
- await browser.close()
- }
- const failed = results.filter((r) => !r.pass)
- console.log('')
- console.log(`T41 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.id}`)
- process.exit(1)
- }
- console.log('T41 定位与锚定真机走查全部通过。')
|