/** * 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 定位与锚定真机走查全部通过。')