/** * T17 真机走查:基础设施模式四块面板按真实端点画出来、四块各自成败、点可定位对象真的联动地图(票 17)。 * * 逻辑脚本(t17-infrastructure-panels.mjs,49/49)已经把契约适配、视图计划、四块独立成败、 * 行业映射与定位判定测透了;这里只补**只有在真机才成立**的部分: * 1. 进基础设施模式后,**界面真的**把四块面板画出来(DOM 实证,不是读 store); * 2. 一块端点坏掉时,坏的那块显示失败措辞,**好的三块仍然画出真实内容** * —— 这是本票验收里唯一只有真机才能验的形态(与票 11 同款); * 3. 点面板里的可定位对象 → 相机真的动了 + 打开了同一张点位详情卡(验收第 6 条); * 4. 面板展开/收起仍真实改变高度(票 01 的语义不回退); * 5. 四块面板的数据适配在 DOM 上看得见(不是只有 store 里有值)。 * * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行): * node .scratch/cockpit-shell-panel-layout/verify/t17-infrastructure-panels-walkthrough.mjs * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时) * * **场景注入的关键**(与票 11 同一套手法):用 `__COCKPIT_MOCK_ONLY__` 把异常样例 * **只**施加在基础设施的三个端点上,地图与其它分区保持 success —— 否则全局 empty 会把地图清空, * 「点可定位对象」就退化成"查不到点位"的假失败。 * * 演示开关(宿主全局键注入,不改代码重新构建): * `__COCKPIT_MOCK_ONLY__='/infrastructure/'` + `__COCKPIT_MOCK_SCENARIO__='failed'` * —— 基础设施四块里三块失败、地图与其它分区照常 * `...SCENARIO__='infrastructure-partial'` —— 应急保障的「防护目标」段没取到,其余段照常画 * `...SCENARIO__='empty'` —— 四块都是"该面板当前没有数据" */ 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' /** 异常样例只施加在基础设施族端点上(地图与点位摘要保持 success,否则没有可定位的点位)。 */ const INFRA_ONLY = '/infrastructure/' 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-t17-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(状态标签 / 块类型 / KPI / 分布 / 清单 / 可定位按钮 / 缺口说明) * + 地图侧口径(标记数、相机、详情卡)。 * * 面板内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。 */ const readPanels = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const panels = Array.from(document.querySelectorAll('[data-panel-blocks]')) const card = document.querySelector('[data-point-detail]') const dev = window.__cockpitMap const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null const camera = dev && dev.getCameraState ? dev.getCameraState() : null const panelView = (el) => ({ id: el.dataset.panelId || '', state: (el.querySelector('[data-section-state]') || {}).dataset?.sectionState || '', stateText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(), blocks: Array.from(el.querySelectorAll('[data-panel-block]')).map((b) => b.dataset.panelBlock || ''), kpis: Array.from(el.querySelectorAll('[data-kpi]')).map((k) => ({ key: k.dataset.kpi || '', label: ((k.querySelector('.panel-kpi__label') || {}).textContent || '').trim(), value: ((k.querySelector('.panel-kpi__value') || {}).textContent || '').trim(), })), bars: Array.from(el.querySelectorAll('[data-bar]')).map((b) => ({ key: b.dataset.bar || '', label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(), count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(), })), items: Array.from(el.querySelectorAll('[data-item]')).map((i) => ({ key: i.dataset.item || '', title: ((i.querySelector('.panel-item__title') || {}).textContent || '').trim(), meta: ((i.querySelector('.panel-item__meta') || {}).textContent || '').trim(), })), locatePoints: Array.from(el.querySelectorAll('[data-locate-point]')).map((b) => b.dataset.locatePoint || ''), gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(), locateNote: ((el.querySelector('[data-locate-note]') || {}).textContent || '').trim(), missingText: ((el.querySelector('[data-panel-missing]') || {}).textContent || '').trim(), placeholder: ((el.querySelector('[data-panel-placeholder]') || {}).textContent || '').trim(), itemState: ((el.querySelector('[data-item-state]') || {}).textContent || '').trim(), // 每段(list 块)自己的标题与状态说明 —— 按段断言,不靠"某一个选择器恰好命中"。 listSections: Array.from(el.querySelectorAll('[data-panel-block="list"]')).map((sec) => ({ label: ((sec.querySelector('.panel-block__title') || {}).textContent || '').trim(), stateText: ((sec.querySelector('[data-item-state]') || {}).textContent || '').trim(), emptyText: ((sec.querySelector('[data-item-empty]') || {}).textContent || '').trim(), rows: sec.querySelectorAll('[data-item]').length, })), barEmpty: ((el.querySelector('[data-bar-empty]') || {}).textContent || '').trim(), // 面板在页面上的可见高度(展开/收起与"真的画出来了"都靠它) height: Math.round(el.getBoundingClientRect().height), }) return { mode: (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent?.trim() || '', panelCount: panels.length, panels: panels.map(panelView), // 本模式下仍是占位的块数(票 20 起 16 块已全部接上真面板,故这里是 0) placeholders: document.querySelectorAll('.cockpit-placeholder').length, locateFeedback: ((document.querySelector('[data-locate-result]') || {}).textContent || '').trim(), locateResult: (document.querySelector('[data-locate-result]') || {}).dataset?.locateResult || '', // 地图侧 engine: (host && host.dataset.mapEngine) || '', markers: markers ? markers.onMap : -1, camera: camera ? camera.pivot : null, hasCard: !!card, cardPointCode: card ? card.dataset.pointCode : '', detailLevel: card ? card.dataset.detailLevel : '', visibleText: document.querySelector('.cockpit-shell__column') ? document.querySelector('.cockpit-shell__column').innerText : '', } }) /** 打开页面 → 切到基础设施模式 → 走真实的进入引导。 */ async function openPage(scenario, onlyPath = INFRA_ONLY) { 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, only }) => { window.__COCKPIT_MOCK_SCENARIO__ = name // 两个键语义不同(见 `mocks/index.js`):`MOCK_ONLY` 是**精确**路径, // `MOCK_PREFIX` 才按前缀选中一族端点。写错键的后果是"异常样例根本没生效", // 而走查仍会绿 —— 那是假绿,所以这里显式按结尾斜杠分派。 if (only.endsWith('/')) window.__COCKPIT_MOCK_PREFIX__ = only else window.__COCKPIT_MOCK_ONLY__ = only // 页大小给大一点:一次翻完,免得"点位还没加载到"把定位测成假失败。 window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 } }, { name: scenario, only: onlyPath }, ) 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) // 切到基础设施模式(一级模式切换不重建地图,票 01)。 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.querySelectorAll('[data-panel-blocks]').length === 4 && Array.from(document.querySelectorAll('[data-panel-blocks] [data-section-state]')).length >= 4, { timeout: 20000 }, ) .catch(() => {}) await sleep(800) return { page, errors } } /** 翻完地图摘要分页(可定位对象的坐标来自已加载的地图对象,所以要等它到位)。 */ async function advanceUntilSettled(page, maxSteps = 8) { for (let step = 0; step < maxSteps; step += 1) { const phase = await page.evaluate(() => (document.querySelector('.cockpit-shell__map .map-canvas') || {}).dataset?.mapSummary || '') if (['complete', 'empty', 'failed', 'expired'].includes(phase)) 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 await sleep(500) } return page.evaluate(() => Number((document.querySelector('.cockpit-shell__map .map-canvas') || {}).dataset?.mapSummaryLoaded || 0)) } /** 等相机真的落到目标附近(阻尼飞行途中读数不准)。 */ async function waitForCameraNear(page, target, timeout = 20000) { await page .waitForFunction( (expected) => { const camera = window.__cockpitMap && window.__cockpitMap.getCameraState ? window.__cockpitMap.getCameraState() : null if (!camera || !camera.pivot) return false const dLon = Math.abs(camera.pivot.lon - expected.lon) const dLat = Math.abs(camera.pivot.lat - expected.lat) return dLon < 0.01 && dLat < 0.01 }, { timeout }, target, ) .catch(() => {}) } const panelOf = (state, id) => state.panels.find((p) => p.id === id) || null // ================================================================ A 正常场景:四块都画出来 { const { page, errors } = await openPage('success') const state = await readPanels(page) check('A1-infrastructure-mode-active', state.mode === '基础设施', `当前一级模式:${state.mode}`) check('A2-four-panels-rendered', state.panelCount === 4, `画出的面板数:${state.panelCount}`) check( 'A3-all-four-panels-succeeded', state.panels.every((p) => p.state === 'success'), state.panels.map((p) => `${p.id}=${p.state}`).join(' '), ) const archive = panelOf(state, 'archive') check( 'A4-archive-shows-scale-industry-region-age', archive.kpis.some((k) => k.label === '设施总数' && k.value.includes('5')) && archive.bars.some((b) => b.label === '燃气' || b.label === 'GAS') && archive.bars.some((b) => b.count === '3') && archive.blocks.includes('bars'), `档案 KPI ${archive.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};分布项 ${archive.bars.length} 条`, ) // 规模必须是中文(真机走查逮到过一次英文枚举直接上屏:`规模=medium`)。 // 这条按**取值**断言,不是"只要不是空就行"。 const scaleKpi = archive.kpis.find((k) => k.label === '规模') check( 'A4b-scale-shown-in-chinese', !!scaleKpi && ['大型', '中型', '小型'].includes(scaleKpi.value), `规模读数:${scaleKpi ? scaleKpi.value : '(缺 KPI)'}(应为大型/中型/小型之一,不得直接印英文枚举)`, ) const operation = panelOf(state, 'operation') check( 'A5-operation-shows-three-capabilities', operation.kpis.map((k) => k.label).join(',') === '供应,输送,承载' && operation.kpis.every((k) => k.value && k.value !== '—'), `三类能力:${operation.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')}`, ) const management = panelOf(state, 'management') check( 'A6-management-shows-units-and-region-gap', management.items.length >= 2 && !!management.gap && management.gap.includes('责任区域'), `${management.items.length} 家管理单位;责任区域如实标为契约缺口`, ) const emergency = panelOf(state, 'emergency') check( 'A7-emergency-shows-three-sections', emergency.kpis.map((k) => k.label).join(',') === '危险源,防护目标,应急资源' && emergency.items.length >= 3, `三段 KPI ${emergency.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};条目 ${emergency.items.length} 条`, ) // 「其余面板仍是占位」要分两步验:基础设施模式**自己就是**那 4 块真实面板, // 所以这里占位应当是 0;切到别的模式才该看到占位。 check( 'A8-infrastructure-mode-has-no-placeholders', state.placeholders === 0, `基础设施模式下占位面板:${state.placeholders} 块(该模式 4 块全是真实面板)`, ) // 切到总体概览:**本票不锁死它的占位块数**。 // // 票 20 修正(2026-09-29):这里原先断言"恰好 4 块占位"(票 18/19/20 的域), // 而票 20 把总体概览四块接上真面板之后它必然假红 —— **判据钉在了另一张票的进度上**。 // 票 18 在**完全相同**的处境下已经栽过一次(其 A8b 原判据"切到预警处置 → 恰好 4 块占位" // 被票 19 接真面板后打红),并在自己那侧改成了"只断言本票模式、其余只汇报"。 // 本票沿用同一办法:**只验本票自己的模式是 0**,别的模式的数量仅作汇报。 await page.evaluate(() => { const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '总体概览') if (button) button.click() }) await sleep(600) const overviewPlaceholders = await page.evaluate(() => document.querySelectorAll('.cockpit-placeholder').length) const overviewReal = await page.evaluate(() => document.querySelectorAll('[data-panel-blocks]').length) check( 'A8b-other-modes-placeholders-are-reported-not-pinned', // 不断言具体块数:只要求"真面板 + 占位"加起来正好是本模式的 4 块面板 // (即没有面板凭空消失或多出来),这样别的票推进不会再让本票假红。 overviewReal + overviewPlaceholders === 4, `切到总体概览:真面板 ${overviewReal} 块 + 占位 ${overviewPlaceholders} 块 = 4(只汇报,不钉死谁接没接)`, ) // 切回基础设施,后面的 B/C/D 组要在本模式的画面上跑。 await page.evaluate(() => { const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '基础设施') if (button) button.click() }) await sleep(600) // 接口词汇不许漏到大屏上(票 05/11 各逮到过一次)。 // 守卫面要覆盖**两类**词:状态/原因码(READY、NO_OBJECTS…)与**枚举值**(行业码、规模、权属…)。 // 最初只查了前者,于是 `规模=medium` 这条英文枚举漏了过去 —— 真机走查才逮到。 const leaks = [ // 状态与原因码 'READY', 'NO_OBJECTS', 'NO_METRICS', 'SAMPLE_CURRENT', 'SOURCE_UNAVAILABLE', 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'EMPTY', 'STALE', // 枚举值(经 domain 直译后才该出现在界面上) 'GAS', 'WATER_SUPPLY', 'DRAINAGE', 'SUPPLY', 'FLOW', 'CAPACITY', 'PRESSURE', 'QUALITY', 'OVERLOAD', 'WATCH', 'NORMAL', 'CURRENT', 'OWNED', 'LEASED', 'ENTRUSTED', 'EMERGENCY_RESOURCE', 'HAZARD_SOURCE', 'PROTECTION_TARGET', 'large', 'medium', 'small', 'UNKNOWN', 'CURRENT_BASELINE', 'LAST_24_HOURS', ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.visibleText)) check( 'A9-no-backend-vocabulary-on-screen', leaks.length === 0, leaks.length ? `界面出现后端代号:${leaks.join(', ')}` : `两栏可见文本零后端代号(查了 ${26} 个状态码与枚举值)`, ) check('A10-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误') // ---- B 验收第 6 条:点可定位对象真的联动地图 ---- const loaded = await advanceUntilSettled(page) const before = await readPanels(page) const locateTargets = (panelOf(before, 'operation')?.locatePoints || []).concat(panelOf(before, 'emergency')?.locatePoints || []) check( 'B1-panels-expose-locatable-points', locateTargets.length > 0 && loaded > 0, `可定位对象 ${locateTargets.length} 个;地图已加载 ${loaded} 个对象`, ) if (locateTargets.length > 0) { // 取一个**可上图**的点位(MP-003 在样例里是 UNVERIFIED,不该被飞过去)。 const target = locateTargets.find((code) => code === 'MP-001' || code === 'MP-002') || locateTargets[0] const expected = await page.evaluate((code) => { const dev = window.__cockpitMap const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null const found = ((markers && markers.markers) || []).find((m) => m.code === code) return found ? { lon: found.longitude, lat: found.latitude } : null }, target) await page.evaluate((code) => { const button = document.querySelector(`[data-locate-point="${code}"]`) if (button) button.click() }, target) if (expected) await waitForCameraNear(page, expected) await sleep(900) const after = await readPanels(page) check( 'B2-locate-moves-camera', !!expected && !!after.camera && Math.abs(after.camera.lon - expected.lon) < 0.01 && Math.abs(after.camera.lat - expected.lat) < 0.01, `相机中心 ${after.camera ? `${after.camera.lon.toFixed(5)},${after.camera.lat.toFixed(5)}` : 'null'}(目标 ${expected ? `${expected.lon},${expected.lat}` : '未知'})`, ) check( 'B3-locate-opens-same-point-detail', after.hasCard && after.cardPointCode === target && after.detailLevel === 'point', `详情卡:level=${after.detailLevel} pointCode=${after.cardPointCode}(点的是 ${target})`, ) check('B4-locate-success-shows-no-warning', after.locateResult === '', `成功后不给多余文字(result="${after.locateResult}")`) check( 'B5-markers-unchanged-by-locate', after.markers === before.markers, `定位前后地图标记数不变:${before.markers} → ${after.markers}(面板不往地图上加对象)`, ) } // ---- C 可定位对象不可上图时:如实说明,不飞 ---- // 样例里 MP-003 的坐标是 UNVERIFIED(票 07 判为不可上图),点它必须给说明而不是飞过去。 // 它出现在**应急保障**的危险源条目上(运行信息面板只画第一个行业块,里面没有 MP-003)。 const blockedTarget = (panelOf(before, 'emergency')?.locatePoints || []).find((code) => code === 'MP-003') if (blockedTarget) { // 注意:B 组刚打开过 MP-002 的详情卡,所以这里**不能**断言"没有卡片"—— // 要断言的是"卡片没有被换成被拒的那个点位"(那才是"没飞过去"的证据)。 const beforeBlocked = await readPanels(page) await page.evaluate((code) => { const button = document.querySelector(`[data-locate-point="${code}"]`) if (button) button.click() }, blockedTarget) await sleep(900) const blocked = await readPanels(page) check( 'C1-blocked-point-explains-and-does-not-fly', blocked.cardPointCode !== blockedTarget && !!blocked.locateResult && JSON.stringify(beforeBlocked.camera) === JSON.stringify(blocked.camera), `点不可上图的 ${blockedTarget}:给出说明「${blocked.locateFeedback}」,相机没动、详情卡仍停在 ${blocked.cardPointCode || '(无)'}`, ) } // ---- D 展开/收起仍真实改变高度(票 01 的语义不回退) ---- const expanded = await page.evaluate(() => { const panel = document.querySelector('[data-panel-blocks="archive"]') const shellPanels = Array.from(document.querySelectorAll('.cockpit-panel')) const own = shellPanels.find((p) => p.contains(panel)) return own ? Math.round(own.getBoundingClientRect().height) : 0 }) await page.evaluate(() => { const panel = document.querySelector('[data-panel-blocks="archive"]') const own = Array.from(document.querySelectorAll('.cockpit-panel')).find((p) => p.contains(panel)) const head = own && own.querySelector('.cockpit-panel__head') if (head) head.click() }) await sleep(500) const collapsed = await page.evaluate(() => { const panel = document.querySelector('[data-panel-blocks="archive"]') const own = Array.from(document.querySelectorAll('.cockpit-panel')).find((p) => p.contains(panel)) return own ? Math.round(own.getBoundingClientRect().height) : 0 }) check( 'D1-collapse-changes-height-not-just-visibility', collapsed > 0 && collapsed < expanded, `档案面板高 ${expanded} → ${collapsed}px(收起真的缩回标题栏高度)`, ) await page.close() } // ================================================================ E 一块坏掉不遮其它三块 // // 两个子场景都用**新开的页面**(`openPage` 的 `onlyPath` 直接选中一个端点): // 不用「现场改全局键再 reload」那套 —— `evaluateOnNewDocument` 在每次导航时都会重放, // reload 会把现场设置冲回去,那样测出来的其实是场景 A,断言就成了假绿。 { // E1/E2:四个端点全坏 —— 四块都该说清楚,而不是白屏。 const { page, errors } = await openPage('failed') const state = await readPanels(page) check( 'E1-all-infrastructure-panels-failed', state.panels.length === 4 && state.panels.every((p) => p.state !== 'success'), state.panels.map((p) => `${p.id}=${p.state}`).join(' '), ) check( 'E2-failed-panels-say-why-not-blank', state.panels.every((p) => p.placeholder.includes('加载失败') || p.placeholder.includes('不可用')), `失败措辞:${state.panels.map((p) => p.placeholder).join(' / ')}`, ) check('E2b-failure-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误') await page.close() // E3/E4:只让**管理信息**这一个端点坏,其余三块必须照常画出真实内容(验收第 3 条的形态)。 const { page: isolatedPage, errors: isolatedErrors } = await openPage('failed', '/infrastructure/management-information') const isolated = await readPanels(isolatedPage) const bad = isolated.panels.filter((p) => p.state !== 'success') check( 'E3-one-panel-failure-does-not-cover-others', bad.length === 1 && bad[0].id === 'management' && isolated.panels.filter((p) => p.state === 'success').length === 3, `${bad.map((p) => p.id).join(',') || '无'} 失败;其余 ${isolated.panels.filter((p) => p.state === 'success').length} 块成功`, ) check( 'E4-good-panels-still-render-real-content', (panelOf(isolated, 'archive')?.kpis.length || 0) > 0 && (panelOf(isolated, 'emergency')?.items.length || 0) > 0, `档案 KPI ${panelOf(isolated, 'archive')?.kpis.length} 个、应急条目 ${panelOf(isolated, 'emergency')?.items.length} 条照常画出`, ) check( 'E5-isolated-run-has-no-page-errors', isolatedErrors.length === 0, isolatedErrors.length ? isolatedErrors.join(' | ') : '无页面错误', ) await isolatedPage.close() } // ================================================================ F 分段失败与空场景 { const { page } = await openPage('infrastructure-partial') const state = await readPanels(page) const emergency = panelOf(state, 'emergency') // 按**段**断言(不是"某个选择器命中就行"):防护目标段该说"没取到", // 危险源与应急资源两段照常画出条目 —— 这正是"一段坏了不遮盖其它段"的形态。 const targetSection = (emergency.listSections || []).find((s) => s.label === '防护目标') const hazardSection = (emergency.listSections || []).find((s) => s.label === '危险源') const resourceSection = (emergency.listSections || []).find((s) => s.label === '应急资源') check( 'F1-child-section-unavailable-is-distinct-from-empty', !!targetSection && targetSection.stateText === '该段数据未取到' && targetSection.rows === 0 && !!hazardSection && hazardSection.rows > 0 && !!resourceSection && resourceSection.rows > 0, `防护目标段「${targetSection ? targetSection.stateText : '(段缺失)'}」且 0 条;危险源 ${hazardSection ? hazardSection.rows : 0} 条、应急资源 ${resourceSection ? resourceSection.rows : 0} 条照常画出`, ) await page.close() const { page: emptyPage } = await openPage('empty') const emptyState = await readPanels(emptyPage) check( 'F2-empty-scenario-explicit-placeholders', emptyState.panels.length === 4 && emptyState.panels.every((p) => p.placeholder === '该面板当前没有数据'), `四块占位说明:${emptyState.panels.map((p) => p.placeholder).join(' / ')}`, ) check( 'F3-empty-does-not-fake-zeros', emptyState.panels.every((p) => !p.kpis.some((k) => k.value === '0')), '空场景不显示伪造的 0 读数(缺值一律占位符)', ) await emptyPage.close() } await browser.close() const failed = results.filter((item) => !item.pass) console.log('') console.log(`T17 真机走查:${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('T17 基础设施模式面板走查全部通过。')