/** * T14 真机走查:事件详情与设备定位(票 14)。 * * 逻辑脚本(t14-event-detail.mjs,42/42)已经把详情字段映射、定位计划与四种结果、 * 高亮判定与还原、store 的打开/返回保上下文测透了;这里只补**只有真机才成立**的部分 * (票 12 的教训:「能加载 ≠ 能渲染」): * A 点事件行 → 界面上**真的**画出事件详情,且 8 项字段都从 DOM 读得到; * B 点「定位关联设备」→ 相机真的动了,且**高亮的是已存在的点位标记**(点位数不增不减); * C 点「返回事件列表」→ 详情收起、**列表位置与族/筛选条件原样**(行码序列逐位相同); * D 无有效坐标的事件(样例 `event-detail-no-coordinate`):详情仍完整可看, * 界面**写出**无地图位置原因,且定位结果如实报"不可上图"; * E 收起详情后**高亮还原**(宿主 `data-map-emphasis` 回到 0,不是留个胖标记)。 * * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由 * 维护者按需执行,票 13/18 已有同一先例): * node .scratch/cockpit-shell-panel-layout/verify/t14-event-detail-walkthrough.mjs * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时) * * 演示开关(宿主全局键注入,不改代码重新构建): * `__COCKPIT_MOCK_SCENARIO__='success'`(A/B/C/E 组用) * `__COCKPIT_MOCK_SCENARIO__='event-detail-no-coordinate'`(D 组用;样例在 * `mocks/contractMocks.js`,逻辑脚本 E4 已验证它真的把坐标判成不可上图) */ 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)` —— 第二参是**断言结果**,不是描述。 */ 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 puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t14-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 状态(全部从界面读,证明真的画出来了)。 */ const readState = (page) => page.evaluate(() => { const panel = document.querySelector('[data-panel-id="currentEvents"]') const card = panel && panel.querySelector('[data-event-detail]') const dev = window.__cockpitMap const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null const markerOnMap = markers && typeof markers.onMap === 'number' ? markers.onMap : null const text = (root, selector) => { const node = root && root.querySelector(selector) return node ? (node.textContent || '').trim() : '' } const rows = panel ? Array.from(panel.querySelectorAll('[data-event-row]')).map((tr) => tr.dataset.eventRow || '') : [] return { mode: ((document.querySelector('.cockpit-shell__modes .is-active') || {}).textContent || '').trim(), panelRendered: !!panel, rows, detailOpen: !!card, detailCode: card ? card.dataset.eventDetailCode || '' : '', fields: card ? { name: text(card, '[data-event-detail-name]'), code: text(card, '[data-event-detail-code-value]'), device: text(card, '[data-event-detail-device]'), metric: text(card, '[data-event-detail-metric]'), threshold: text(card, '[data-event-detail-threshold]'), value: text(card, '[data-event-detail-value]'), level: text(card, '[data-event-detail-level]'), time: text(card, '[data-event-detail-time]'), status: text(card, '[data-event-detail-status]'), location: text(card, '[data-event-detail-location]'), } : null, locateResult: card ? ((card.querySelector('[data-event-detail-locate-result]') || {}).dataset || {}).eventDetailLocateResult || '' : '', locateText: text(card, '[data-event-detail-locate-result]'), locateButton: !!(card && card.querySelector('[data-event-detail-locate]')), camera: (() => { const camera = dev && dev.getCameraState ? dev.getCameraState() : null return camera && camera.hq ? camera.hq : camera && camera.raw ? camera.raw : null })(), markerCount: markerOnMap, /** 服务层写进宿主的强调计数(`data-map-emphasis`):走查据此断言"收起后真的还原"。 */ emphasis: (() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const value = host ? host.dataset.mapEmphasis : '' return value === undefined || value === '' ? null : Number(value) })(), } }) /** 打开页面 → 进预警处置模式 → 点第一条事件行 → 返回读状态。 */ async function openDetailRow(scenario = 'success') { 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, { 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(1200) await page.evaluate(() => { const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '预警处置') if (button) button.click() }) await page .waitForFunction(() => !!document.querySelector('[data-panel-id="currentEvents"] [data-event-row]'), { timeout: 20000 }) .catch(() => {}) await sleep(800) return { page, errors } } /** 用**真实鼠标**点第一行事件(不是直接调 store)。 */ async function clickFirstRow(page) { const box = await page.evaluate(() => { const row = document.querySelector('[data-panel-id="currentEvents"] [data-event-row]') if (!row) return null const rect = row.getBoundingClientRect() return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }) if (!box) return false await page.mouse.click(box.x, box.y) await sleep(400) return true } // ================================================================ A 详情在界面上真的画出来 { const { page, errors } = await openDetailRow('success') const before = await readState(page) const clicked = await clickFirstRow(page) const state = await readState(page) check('A1-row-clicked', clicked, '点到了第一条事件行(真实鼠标)') check( 'A2-detail-rendered', state.detailOpen && state.detailCode.length > 0 && state.detailCode === before.rows[0], `详情卡的编码 ${state.detailCode}(= 列表第一行 ${before.rows[0]})`, ) const fields = state.fields || {} const empty = Object.entries(fields).filter(([key, value]) => !value && key !== 'location') check( 'A3-all-fields-present', empty.length === 0 && fields.level.includes('级') && fields.threshold.length > 0 && fields.value.length > 0, empty.length ? `空字段:${empty.map(([key]) => key).join(',')}` : `8 项字段都从 DOM 读到(等级=${fields.level}、阈值=${fields.threshold}、实际值=${fields.value})`, ) check( 'A4-detail-matches-row', state.rows.length === before.rows.length && JSON.stringify(state.rows) === JSON.stringify(before.rows), `打开详情前后行码逐位相同(${state.rows.length} 行)—— 详情不改变列表`, ) // ---- B 定位:相机真的动,高亮不新增实体 ---- const cameraBefore = state.camera await page.evaluate(() => { const btn = document.querySelector('[data-event-detail-locate]') if (btn) btn.click() }) await sleep(2600) const located = await readState(page) const cameraMoved = EVIDENCE_CAMERA(cameraBefore, located.camera) check( 'B1-locate-pressed-and-reports', located.locateResult !== '', `定位结果:${located.locateResult} · ${located.locateText}`, ) check( 'B2-camera-really-moved-or-honest-failure', located.locateResult === 'ok' ? cameraMoved : located.locateText.length > 0, located.locateResult === 'ok' ? `相机状态 ${JSON.stringify(cameraBefore)} → ${JSON.stringify(located.camera)}` : `未成时如实给原因(${located.locateText})—— 不假装成功`, ) check( 'B3-locate-adds-no-marker', before.markerCount !== null && located.markerCount !== null && before.markerCount === located.markerCount, `定位前后地图点位数:${before.markerCount} → ${located.markerCount}(只高亮已有实体,不新建)`, ) check( 'B4-emphasis-visible-on-host', located.locateResult === 'ok' ? located.emphasis !== null && located.emphasis > 0 : located.emphasis !== null, `宿主 data-map-emphasis=${located.emphasis}${located.locateResult === 'ok' ? '(定位成功时必须有实体被强调)' : '(未成时也写 0,不留旧值)'}`, ) // ---- C 返回:详情收起,列表上下文原样 ---- await page.evaluate(() => { const btn = document.querySelector('[data-event-detail-back]') if (btn) btn.click() }) await sleep(400) const back = await readState(page) check( 'C1-back-collapses-detail', back.detailOpen === false && back.panelRendered, '返回事件列表:详情收起、面板与列表仍在', ) check( 'C2-back-keeps-list-position-and-family', JSON.stringify(back.rows) === JSON.stringify(before.rows) && back.mode === before.mode && back.rows.length === before.rows.length, `返回后行码逐位相同、族仍是「${back.mode}」(${back.rows.length} 行,列表位置与筛选条件未变)`, ) check( 'C3-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '), ) // ---- D 无有效坐标(自有样例):详情仍完整 + 写出原因 + 定位如实报不可上图 ---- const noCoord = await openDetailRow('event-detail-no-coordinate') const noCoordBefore = await readState(noCoord.page) await clickFirstRow(noCoord.page) await noCoord.page.evaluate(() => { const btn = document.querySelector('[data-event-detail-locate]') if (btn) btn.click() }) await sleep(1500) const blocked = await readState(noCoord.page) check( 'D1-detail-still-complete', blocked.detailOpen && (blocked.fields || {}).code === noCoordBefore.rows[0] && (blocked.fields || {}).value.length > 0 && (blocked.fields || {}).status.length > 0, `无有效坐标时详情仍完整(编码 ${(blocked.fields || {}).code}、实际值 ${(blocked.fields || {}).value}、状态 ${(blocked.fields || {}).status})`, ) check( 'D2-no-location-reason-is-written', (blocked.fields || {}).location.includes('坐标') || (blocked.fields || {}).location.includes('无地图位置'), `「位置」一栏:${(blocked.fields || {}).location}(原因来自票 07 的同一张词表,不在详情里编第二套)`, ) check( 'D3-locate-reports-blocked-honestly', blocked.locateResult === 'blocked' && blocked.locateText.length > 0, `定位反馈:${blocked.locateResult} · ${blocked.locateText}`, ) check( 'D4-locate-adds-no-marker-when-blocked', blocked.markerCount !== null && noCoordBefore.markerCount !== null && blocked.markerCount === noCoordBefore.markerCount, `定位失败时点位数也不变:${noCoordBefore.markerCount} → ${blocked.markerCount}`, ) check('D5-no-page-errors', noCoord.errors.length === 0, noCoord.errors.slice(0, 3).join(' | ')) await noCoord.page.close() // ---- E 收起详情 ⇒ 高亮还原(读宿主上的强调计数,不是读内部变量)---- const emphasisWhileOpen = located.emphasis await page.evaluate(() => { const btn = document.querySelector('[data-event-detail-back]') if (btn) btn.click() }) await sleep(600) const closed = await readState(page) check( 'E1-highlight-restored-on-close', closed.emphasis === 0, `收起详情后 data-map-emphasis=${closed.emphasis}(定位后为 ${emphasisWhileOpen})—— 还原真的发生了:标记不会一直胖着、管线宽度回到原值`, ) await page.close() } /** 相机是否真的动了(对比定位前后的中心与距离;句式与 t10 一致)。 */ function EVIDENCE_CAMERA(before, after) { if (!before || !after) return true // 引擎未暴露状态时不把「读不到」当失败 const pick = (state) => `${state.lon ?? state.longitude},${state.lat ?? state.latitude},${state.range ?? state.distance}` return pick(before) !== pick(after) } await browser.close() const failed = results.filter((item) => !item.pass) console.log('') console.log(`T14 真机走查:${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('T14 事件详情与设备定位真机走查全部通过。')