/** * T07 真机走查:坐标准入与「无地图位置」在浏览器里确实成立(票 07)。 * * 逻辑脚本(t07-coordinate.mjs)已把准入判据、六种原因文案、两个口径与清单渲染断言透了; * 这里只补**只有在真机才成立**的部分 —— * 1. 被拦下的对象确实**没进**「可上图」那一堆,而对象本身仍在界面上看得见(不是静默隐藏); * 2. 「无地图位置」清单带着**具体中文原因**真的渲染出来了; * 3. 后端全量口径与已加载口径在界面/宿主上是**两个数**,且与 mock 载荷对得上。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务): * node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs * * 演示用的开关(宿主全局键注入,不改代码重新构建): * `window.__COCKPIT_MOCK_MAP__ = { pageSize }` —— mock 五条样例里就有三类坏坐标。 */ 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-t07-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()}`) /** 读坐标准入的界面状态(断言读 dataset 与 `data-*`,不读中文串;文案另行核对)。 */ const readCoordinate = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const panel = document.querySelector('[data-map-blocked-panel]') const list = document.querySelector('[data-map-blocked-list]') const items = list ? Array.from(list.querySelectorAll('li[data-map-blocked-reason]')) : [] return { engine: (host && host.dataset.mapEngine) || '', summary: (host && host.dataset.mapSummary) || '', loaded: Number((host && host.dataset.mapSummaryLoaded) || 0), total: Number((host && host.dataset.mapSummaryTotal) || 0), renderable: Number((host && host.dataset.mapRenderable) || 0), blocked: Number((host && host.dataset.mapBlocked) || 0), blockedBackend: (host && host.dataset.mapBlockedBackend) || '', crs: (host && host.dataset.mapCoordinateCrs) || '', panelCount: document.querySelectorAll('[data-map-blocked-panel]').length, panelExists: !!panel, countText: (document.querySelector('[data-map-blocked-count]') || {}).textContent || '', // 用**面板内**的选择器,不要用 `[data-map-blocked-backend]`:那个名字同时挂在 // 地图宿主上(dataset),宿主在 DOM 里更靠前,querySelector 会先命中宿主, // 拿回整页文本 —— 断言就成了「拿整页文字做 includes」,是假绿(第一版踩过)。 backendText: (panel?.querySelector('[data-map-coordinate-backend]') || {}).textContent || '', loadedText: (panel?.querySelector('[data-map-coordinate-loaded]') || {}).textContent || '', listVisible: !!list, reasons: items.map((li) => ({ code: li.dataset.mapBlockedReason || '', text: (li.querySelector('.map-blocked__reason') || {}).textContent?.trim() || '', name: (li.querySelector('.map-blocked__name') || {}).textContent?.trim() || '', })), // 宿主内的 Cesium 画布(口径与票 27 的 A15 一致:`.cesium-widget canvas`) cesiumCanvas: host ? host.querySelectorAll('.cesium-widget canvas').length : -1, } }) /** 点「加载下一页」并等已加载数真的变了(不知增量,所以等"变化"而不是等某个值)。 */ async function clickAndWaitChange(page, previousLoaded, timeout = 8000) { 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) return false await page .waitForFunction( (before) => { 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 !== before || ['failed', 'expired', 'empty'].includes(phase) }, { timeout }, previousLoaded, ) .catch(() => {}) return true } /** * 把分页翻到底(完成/空/失败/失效)。 * * 必须真的翻:外壳挂载后只自动取**第 1 批**(票 08 的渐进加载), * 而 mock 的五条样例按页大小 2 分成三批 —— 不翻页就只持有第 1 批, * 断言会以为"没有坏坐标对象",实际是还没取到(本脚本第一版正是这么写错的)。 */ async function advanceUntilSettled(page, maxSteps = 10) { const series = [await readCoordinate(page)] for (let step = 0; step < maxSteps; step += 1) { const current = series[series.length - 1] if (['complete', 'empty', 'failed', 'expired'].includes(current.summary)) break if (!(await clickAndWaitChange(page, current.loaded))) break series.push(await readCoordinate(page)) } return series } 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()}`) }) await page.evaluateOnNewDocument(() => { // 引导层会盖住页面,走查不需要它(票 30 的开关)。 window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } } }) 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: 30000 }, ) .catch(() => {}) // 外壳挂载后会自动取第 1 批:等它落定再开始(票 08 同一条前置)。 await page .waitForFunction( () => { 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 > 0 || ['empty', 'failed', 'expired'].includes(phase) }, { timeout: 15000 }, ) .catch(() => {}) await sleep(300) return { page, errors } } /** 把「无地图位置」清单展开(默认收起,避免挡住地图)。 */ async function expandList(page) { const clicked = await page.evaluate(() => { const button = document.querySelector('[data-map-blocked-toggle]') if (!button) return false button.click() return true }) if (clicked) await sleep(250) return clicked } try { // ---------- A 被拦下的对象不上图,但仍在界面上看得见 ---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) // 翻到底再断言:只取第 1 批时会看不到后两个坏坐标对象(见 advanceUntilSettled 的说明)。 await advanceUntilSettled(page) const state = await readCoordinate(page) // mock 五条:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。 check( 'A1-renderable-excludes-blocked', state.renderable === 2 && state.blocked === 3 && state.loaded === 5 && state.summary === 'complete', `可上图 ${state.renderable} · 不上图 ${state.blocked} · 已加载 ${state.loaded} · 终态 ${state.summary}`, ) check( 'A2-objects-not-silently-dropped', state.panelExists && state.panelCount === 1 && Number(state.countText) === 3, `清单存在=${state.panelExists} · 计数=${state.countText}`, ) // 后端全量口径(不随翻页跳动)与已加载口径是两个数,且都对得上 mock 载荷。 check( 'A3-backend-caliber-is-whole-snapshot', state.blockedBackend === '3' && state.crs === 'WGS84', `后端全量不上图 ${state.blockedBackend}(非法 2 + 未校验 1)· crs=${state.crs}`, ) check( 'A4-loaded-caliber-is-this-batch', state.loadedText.includes('已加载 5') && state.loadedText.includes('可上图 2') && state.loadedText.includes('无地图位置 3'), state.loadedText, ) check( 'A5-two-calibers-worded-apart', state.backendText.includes('全量') && state.loadedText.includes('已加载') && state.backendText !== state.loadedText, `${state.backendText} | ${state.loadedText}`, ) // 摘要措辞不得把被拦下的 3 个算成「已上图」。 const label = await page.evaluate(() => (document.querySelector('[data-map-summary-label]') || {}).textContent || '') check( 'A6-summary-label-not-inflated', label.includes('已上图 2 个') && label.includes('无地图位置 3 个'), label, ) check('A7-engine-still-alive', state.engine === 'ready' && state.cesiumCanvas === 1, `引擎 ${state.engine} · 画布 ${state.cesiumCanvas}`) check('A8-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- B 「无地图位置」清单带具体中文原因 ---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) await advanceUntilSettled(page) const before = await readCoordinate(page) check('B1-list-collapsed-by-default', before.listVisible === false, `默认展开=${before.listVisible}`) const expanded = await expandList(page) const after = await readCoordinate(page) check('B2-list-expands', expanded && after.listVisible === true, `展开成功=${expanded}`) // 三个被拦下的对象各带**具体原因**(不是笼统的「无地图位置」)。 const texts = after.reasons.map((item) => item.text) check( 'B3-reasons-are-specific', after.reasons.length === 3 && texts.includes('坐标未经校验') && texts.includes('经度超出合法范围') && texts.includes('零坐标(经纬度均为 0)'), JSON.stringify(after.reasons.map((item) => `${item.name || '-'}:${item.text}`)), ) check( 'B4-reason-codes-on-dataset-only', after.reasons.map((item) => item.code).sort().join(',') === 'LONGITUDE_OUT_OF_RANGE,UNVERIFIED_COORDINATE,ZERO_COORDINATE', JSON.stringify(after.reasons.map((item) => item.code)), ) // 界面文案里不得出现后端原因码原文(票 05 立的纪律,票 07 沿用)。 const leaked = after.reasons.filter((item) => /^[A-Z_]{6,}$/.test(item.text)) check('B5-no-raw-reason-code-on-screen', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文') check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- C 空样例与字段缺失样例的坐标准入 ---------- { // 空快照:没有对象 → 无地图位置 0,不报失败。 // 注意后端汇总是**空的**而不是 0:票 05 的 `sectionFromResult` 对 `EMPTY` 分区不带载荷, // 那份全零汇总在适配层就被放下了(数据面确实拿到过,但领域层按契约丢掉)。 // 所以这里断言 dataset 为空串,且界面文案如实说「未取到」而不是编一个 0。 const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'empty' window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot' }) const empty = await readCoordinate(page) check( 'C1-empty-scenario-zero-blocked', empty.summary === 'empty' && empty.blocked === 0 && empty.renderable === 0 && empty.blockedBackend === '' && empty.backendText.includes('未取到'), JSON.stringify({ summary: empty.summary, blocked: empty.blocked, backend: empty.blockedBackend, text: empty.backendText }), ) await page.close() } { // 字段缺失样例:点位没有 coordinateValidation → 「坐标未校验」,仍被列出来(不静默隐藏)。 // 这个样例是**单页且 complete:false**(票 05 的原样),所以只取第 1 批、不继续翻页。 const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'degraded' window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot' }) await expandList(page) const degraded = await readCoordinate(page) check( 'C2-degraded-is-unverified-not-hidden', degraded.loaded === 1 && degraded.renderable === 0 && degraded.blocked === 1 && degraded.reasons[0]?.text === '坐标未校验', JSON.stringify({ loaded: degraded.loaded, reasons: degraded.reasons.map((item) => item.text) }), ) await page.close() } // ---------- D 清单不把地图或页面撑出问题 ---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) await advanceUntilSettled(page) await expandList(page) const layout = await page.evaluate(() => { const shell = document.querySelector('.cockpit-shell') const host = document.querySelector('.cockpit-shell__map .map-canvas') const panel = document.querySelector('[data-map-blocked-panel]') const panelRect = panel ? panel.getBoundingClientRect() : null const hostRect = host ? host.getBoundingClientRect() : null return { panelInsideHost: !!panelRect && !!hostRect && panelRect.left >= hostRect.left - 1 && panelRect.right <= hostRect.right + 1 && panelRect.bottom <= hostRect.bottom + 1, docOverflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth, hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, shellVisible: !!shell && shell.getBoundingClientRect().height > 100, } }) check('D1-panel-contained-in-map', layout.panelInsideHost, JSON.stringify(layout)) check('D2-no-horizontal-overflow', layout.docOverflowX <= 0, `横向溢出 ${layout.docOverflowX}px`) check('D3-single-map-host', layout.hosts === 1 && layout.shellVisible, JSON.stringify({ hosts: layout.hosts })) check('D4-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('') console.log(`T07 真机走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((item) => item.id).join(', ')) process.exit(1) }