/** * T08 真机走查:地图摘要分页与快照在浏览器里确实成立(票 08)。 * * 逻辑脚本(t08-map-summary.mjs)已经把合并、装载器状态机与分区映射测透了;这里只补 * **只有在真机才成立**的部分 —— 票 08 验收第 1 条原句: * 「地图先显示框架,再按分页批次增加摘要点位」。 * 纯逻辑断言证明不了「框架先于点位出现」:那要求页面真的渲染出来、地图宿主与 Cesium 引擎先就绪。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务): * node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs * * 演示用的开关(都由宿主全局键注入,不改代码重新构建): * `window.__COCKPIT_MOCK_SCENARIO__` / `window.__COCKPIT_MOCK_MAP__` * 人工演示同一件事:控制台里 `__cockpitMap.mapSummary.advance()` 一页页推进。 */ 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)` —— 注意参数顺序:第二参是**断言结果**,不是描述。 * (踩过一次:签名多了一个 `desc`,结果每个断言都把证据字符串当成了"通过",走查假绿 11/11。) */ 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-t08-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)。 */ const readSummary = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const action = document.querySelector('[data-map-summary-action]') return { phase: (host && host.dataset.mapSummary) || '', loaded: Number((host && host.dataset.mapSummaryLoaded) || 0), total: Number((host && host.dataset.mapSummaryTotal) || 0), snapshot: (host && host.dataset.mapSummarySnapshot) || '', page: Number((host && host.dataset.mapSummaryPage) || 0), engine: (host && host.dataset.mapEngine) || '', actionLabel: action ? action.textContent.trim() : '', actionDisabled: action ? action.disabled : null, label: (document.querySelector('[data-map-summary-label]') || {}).textContent || '', board: Array.from(document.querySelectorAll('[data-section-board] [data-section-state]')).map((t) => ({ name: t.dataset.sectionName, state: t.dataset.sectionState, retained: t.dataset.sectionRetained, })), hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, } }) 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 的"再按分页批次增加"):等它落定再断言, // 否则会把"正在取第 1 批"误判成"框架就绪但一个点都没有"。 await waitForFirstBatch(page) await sleep(300) return { page, errors } } /** 点「加载下一页」并等**已上图对象数真的变了**(不知增量,所以等"变化"而不是等某个值)。 */ 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 || phase === 'failed' || phase === 'expired' }, { timeout }, previousLoaded, ) .catch(() => {}) return true } /** 等外壳自动取完第 1 批(票 08:地图挂载后自己先取一批,不等用户点)。 */ async function waitForFirstBatch(page, timeout = 15000) { 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 || phase === 'empty' || phase === 'failed' || phase === 'expired' }, { timeout }, ) .catch(() => {}) } /** 一批一批点,直到不再有下一页(完成/空/失败/失效)或点不动了。返回每一步的状态。 */ async function advanceUntilSettled(page, maxSteps = 10) { const series = [await readSummary(page)] for (let step = 0; step < maxSteps; step += 1) { const current = series[series.length - 1] if (['complete', 'empty', 'failed', 'expired'].includes(current.phase)) break if (!(await clickAndWaitChange(page, current.loaded))) break series.push(await readSummary(page)) } return series } try { // ---------- A 「地图先显示框架,再按分页批次增加摘要点位」---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const first = await readSummary(page) // 外壳在挂载后自动取第 1 批(票 08 原句的"再按分页批次增加"):首屏就应看到框架 + 第 1 批, // 而不是等全部取完。`loaded < total` 正是"渐进"的判据。 check( 'A1-frame-before-points', ['ready', 'disabled'].includes(first.engine) && first.phase !== 'failed' && first.loaded > 0 && first.loaded < first.total, JSON.stringify({ engine: first.engine, phase: first.phase, loaded: first.loaded, total: first.total }), ) check('A1-host-alive', first.hosts === 1, `地图宿主 ${first.hosts} 个`) const series = await advanceUntilSettled(page) const loadedSeries = series.map((item) => item.loaded) const snapshots = new Set(series.map((item) => item.snapshot).filter(Boolean)) const last = series[series.length - 1] check( 'A2-progressive-batches', loadedSeries.join(',') === '2,4,5' && last.phase === 'complete' && last.total === 5, `已上图序列 ${loadedSeries.join(' → ')} · 总数 ${last.total} · 终态 ${last.phase}`, ) check( 'A3-one-snapshot-per-load', snapshots.size === 1 && [...snapshots][0] !== '', `快照 ${[...snapshots].join(', ')}`, ) check( 'A4-batch-count-consistent', series.length === 3 && loadedSeries[0] === 2 && loadedSeries[1] === 4, `批次数 ${series.length} · 序列 ${loadedSeries.join(' → ')}`, ) // 取完之后按钮从「加载下一页」变成「刷新快照」:完成态是终态,再点就是换新快照。 check( 'A5-complete-switches-action', last.actionLabel.includes('刷新') && first.actionLabel.includes('加载'), `按钮:${first.actionLabel} → ${last.actionLabel}`, ) check('A6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- B 刷新切换到新快照,旧快照的点不留在图上 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const series = await advanceUntilSettled(page) const completed = series[series.length - 1] // 完成态下点这个按钮 = `mapStore.refresh()`:丢掉当前快照,从第 1 页重来。 await clickAndWaitChange(page, completed.loaded) await sleep(400) const refreshed = await readSummary(page) check( 'B1-refresh-back-to-page-1', refreshed.page === 1 && refreshed.loaded === 2 && refreshed.phase !== 'expired' && refreshed.phase !== 'failed', JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded, phase: refreshed.phase, snapshot: refreshed.snapshot }), ) check( 'B2-old-snapshot-not-accumulated', refreshed.loaded < completed.loaded, `完成时 ${completed.loaded} → 刷新后 ${refreshed.loaded}(旧快照的点没有留下来)`, ) await page.close() } // ---------- C 失败:分区标失败,地图框架与已上图点位保留 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'failed' window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const failed = await readSummary(page) const mapSection = failed.board.find((item) => item.name === 'mapSnapshot') check( 'C1-failed-shows-on-board', failed.phase === 'failed' && !!mapSection && mapSection.state === 'failed' && failed.engine !== '', JSON.stringify({ phase: failed.phase, section: mapSection }), ) check( 'C2-failed-keeps-frame', failed.hosts === 1 && failed.label.includes('失败'), JSON.stringify({ hosts: failed.hosts, label: failed.label }), ) await page.close() } // ---------- D 快照过期:取完第 1 页后后端换快照,界面说「已失效」并保留已加载的点 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'stale' window.__COCKPIT_MOCK_MAP__ = { pageSize: 2, expireAfterPage: 1 } }) // 首屏自动取到的就是第 1 页(旧快照);再点一次,后端已经换快照 → 失效。 const before = await readSummary(page) await clickAndWaitChange(page, before.loaded) await sleep(500) const expired = await readSummary(page) check( 'D1-expired-after-first-page', expired.phase === 'expired' && expired.loaded === before.loaded && expired.label.includes('失效'), JSON.stringify({ before: `${before.phase}/${before.loaded}`, phase: expired.phase, loaded: expired.loaded, label: expired.label }), ) check( 'D2-expired-not-merged', expired.snapshot !== '' && expired.total === before.total, JSON.stringify({ snapshot: expired.snapshot, total: expired.total }), ) await page.close() } // ---------- E 空快照:不是失败,也不是「加载完成」的假象 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'empty' }) const empty = await readSummary(page) const mapSection = empty.board.find((item) => item.name === 'mapSnapshot') check( 'E1-empty-is-neutral', empty.phase === 'empty' && empty.loaded === 0 && (empty.label.includes('没有可上图对象') || empty.label.includes('无数据')), JSON.stringify({ phase: empty.phase, label: empty.label, section: mapSection && mapSection.state }), ) await page.close() } // ---------- F 布局:新增的「地图摘要」控件不得把筛选条撑破(票 08 改到了顶栏)---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 } }) const probe = () => page.evaluate(() => { const scope = document.querySelector('.cockpit-shell__scope') const summary = document.querySelector('.cockpit-shell__map-summary') const doc = document.documentElement return { scopeOverflow: scope ? scope.scrollWidth - scope.clientWidth : -1, summaryVisible: !!summary && summary.getBoundingClientRect().width > 0, summaryInside: summary && scope ? summary.getBoundingClientRect().right <= scope.getBoundingClientRect().right + 1 : false, docOverflow: doc.scrollWidth - doc.clientWidth, } }) const at1680 = await probe() await page.setViewport({ width: 1280, height: 720 }) await sleep(500) const at1280 = await probe() check( 'F1-filter-bar-not-overflowing', at1680.scopeOverflow <= 0 && at1680.docOverflow <= 0 && at1280.scopeOverflow <= 0 && at1280.docOverflow <= 0, JSON.stringify({ at1680, at1280 }), ) check( 'F2-summary-widget-visible-and-inside', at1680.summaryVisible && at1680.summaryInside && at1280.summaryVisible, JSON.stringify({ at1680: { visible: at1680.summaryVisible, inside: at1680.summaryInside }, at1280: { visible: at1280.summaryVisible } }), ) await page.close() } } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T08 走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T08 走查全部通过。')