| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072 |
- /**
- * T18 运行监测模式面板 纯逻辑检查(票 18)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t18-monitoring-panels.mjs
- *
- * 八段:
- * A 面板映射:四块面板齐、与目录一致、不越界接其它模式
- * B 契约适配:三份真形状载荷 → 领域对象(含 BigDecimal 字符串、region 未分区、全六态)
- * C 视图层:验收点名的画面元素都在(规模/在线率/类型分布/采集时间、覆盖/状态分布、
- * 趋势/离线/异常、台账卡片),且缺值显示占位符
- * D 分区独立:一块失败不遮盖其它块;空 / 过期 / 分不开的形态各有措辞
- * E 筛选联动:行业映码、状态白名单、范围走配置映射、**筛选真的进请求串**
- * F 地图联动:筛选真的下发到 map-snapshot(mock 真的过滤,不是只看请求串)
- * G 台账:与分布共用一次取数、按端点分页渐进加载、徽标 N+1 各自成败
- * H 静态守卫:组件不拼路径、零后端裸字段/代号;domain 不发请求;面板渲染层与模式解耦
- *
- * 契约依据:`CockpitMonitoring{Overview,Distribution,Situation}Response` 与服务
- * 逐行读过;见 `domain/monitoringPanels.js` 文件头的三处契约差异记录。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import {
- METRIC_FRESHNESS_GAP,
- METRIC_STATUS_TEXT,
- MONITORING_PANEL_IDS,
- POINT_STATUS_MAP_GAP,
- bucketLabel,
- metricStatusText,
- monitoringDistributionPanel,
- monitoringOverviewPanel,
- monitoringPanelView,
- monitoringPointLedgerPanel,
- nextLedgerPage,
- operationOverviewPanel,
- shouldLoadBadge,
- toMonitoringDistribution,
- toMonitoringOverview,
- toMonitoringSituation,
- } from '../../../src/modules/cockpit/domain/monitoringPanels.js'
- import {
- DEFAULT_POINT_STATUS,
- REGION_MAP_GAP,
- hasRegionMapping,
- industryQuery,
- isKnownPointStatus,
- regionQuery,
- } from '../../../src/modules/cockpit/domain/filterOptions.js'
- import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
- import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
- import { panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
- import { hasPanelView, implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
- import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
- import { normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
- import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
- import {
- fetchMonitoringDistribution,
- fetchMonitoringOverview,
- statusParam,
- } from '../../../src/modules/cockpit/services/monitoringApi.js'
- import { useMonitoringStore } from '../../../src/modules/cockpit/stores/monitoringStore.js'
- import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
- import { useFilterStore } from '../../../src/modules/cockpit/stores/filterStore.js'
- const checks = []
- /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- /** 去掉注释只留代码(注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
- const readCode = (relative) => code(read(relative))
- const readVueCode = (relative) => codeVue(read(relative))
- setActivePinia(createPinia())
- // ================================================================ A 面板映射
- {
- const monitoring = panelsForMode('monitoring')
- check(
- 'A1-four-panels-match-catalog',
- MONITORING_PANEL_IDS.join(',') === 'monitoringOverview,monitoringDistribution,operationOverview,monitoringPointLedger' &&
- monitoring.slice().sort().join(',') === MONITORING_PANEL_IDS.slice().sort().join(','),
- `${MONITORING_PANEL_IDS.length} 块:${MONITORING_PANEL_IDS.join(' / ')}(与 ADR 0003 的目录一致)`,
- )
- check(
- 'A2-panels-are-monitoring-mode-only',
- panelsForMode('infrastructure').every((id) => !MONITORING_PANEL_IDS.includes(id)) &&
- panelsForMode('overview').every((id) => !MONITORING_PANEL_IDS.includes(id)) &&
- panelsForMode('warningResponse').every((id) => !MONITORING_PANEL_IDS.includes(id)),
- '本票只接运行监测模式的四块,不越界接其它模式的面板',
- )
- // 注册表:两个模式的面板都认得,且**没有撞键**(撞了 panelViews 会在加载时抛错)。
- //
- // 票 38 修正:原来这里钉的是 `implementedPanelIds().length === 8`,但**那是个会漂的魔数** ——
- // 别的票接进新面板就会打红(票 13 接预警处置四块后变成 11,本票撞上)。
- // 断言的本意是"本票四块 + 基础设施四块在册、未接的仍回 null",与总数无关,
- // 所以改成按**清单成员**判,既不漏守也不误伤并行票。
- check(
- 'A3-registry-covers-both-modes-and-dispatch-is-by-panelid',
- MONITORING_PANEL_IDS.every((id) => hasPanelView(id)) &&
- hasPanelView('archive') &&
- !hasPanelView('currentEvents') &&
- ['archive', 'operation', 'management', 'emergency', ...MONITORING_PANEL_IDS].every((id) =>
- implementedPanelIds().includes(id),
- ),
- `已接数据的面板 ${implementedPanelIds().length} 块(含基础设施 4 + 运行监测 4);未接的(如 currentEvents)仍回 null 走占位`,
- )
- }
- // ================================================================ B 契约适配
- {
- // B1 监测概览:BigDecimal 字符串 + 分母为 0 的在线率。
- const overview = toMonitoringOverview({
- updatedAt: 'T',
- counts: { monitoringPointCount: 6, equipmentCount: 9 },
- status: { onlineCount: 8, offlineCount: 0, staleCount: 1, unknownOrMissingCount: 0, denominator: 9 },
- onlineRate: { value: '0.8889', numerator: 8, denominator: 9, state: 'AVAILABLE', reason: null },
- typeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 }],
- freshness: { status: 'CURRENT', updatedAt: 'T2', serverUpdatedAt: 'T3' },
- mapFilter: { industry: 'GAS', deviceType: null },
- excluded: {},
- })
- check(
- 'B1-overview-parses-string-bigdecimal',
- overview.onlineRate === 0.8889 && overview.onlineDenominator === 9 && overview.onlineState === 'AVAILABLE',
- `"0.8889" → ${overview.onlineRate}(BigDecimal 在线上是字符串);分母 ${overview.onlineDenominator}`,
- )
- check(
- 'B1b-overview-keeps-type-distribution-order-and-counts',
- overview.typeDistribution.length === 1 &&
- overview.typeDistribution[0].code === 'GAS_SENSOR' &&
- overview.typeDistribution[0].deviceCount === 3,
- `类型分布按后端顺序带出(${overview.typeDistribution[0].name} × ${overview.typeDistribution[0].deviceCount})`,
- )
- // 采集时间不在本端点上 —— 这一条是**如实记录的契约差异**,不是实现细节。
- check(
- 'B1c-metric-freshness-gap-is-declared',
- METRIC_FRESHNESS_GAP.includes('overview/monitoring') && METRIC_FRESHNESS_GAP.includes('采集时间'),
- '「逐设备最近采集时间不在本端点上」写成常量并由界面说明(不伪造一个假的采集时间)',
- )
- // B2 监测分布:三族分布 + 全六态状态计数 + `未分区` 是真实取值。
- const distribution = toMonitoringDistribution({
- updatedAt: 'T',
- freshness: { status: 'CURRENT' },
- regionDistribution: [{ region: '城区A', pointCount: 2, deviceCount: 1, statusCounts: { alarm: 1, warning: 0, workOrder: 0, normal: 1, offline: 0, empty: 0 } }],
- // 空区域归一为字面量「未分区」——它是**真实取值**,界面要照实显示。
- industryDistribution: [{ industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
- deviceTypeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
- pointStatusDistribution: [
- { status: 'ALARM', count: 1 },
- { status: 'WARNING', count: 1 },
- { status: 'WORK_ORDER', count: 0 },
- { status: 'NORMAL', count: 3 },
- { status: 'OFFLINE', count: 1 },
- { status: 'EMPTY', count: 1 },
- ],
- mapFilter: { industry: 'GAS', pointStatus: 'ALARM', region: '城区A' },
- pagination: { page: 1, pageSize: 3, total: 8, totalPages: 3, hasMore: true },
- monitoringPoints: [],
- unassociatedEquipment: [],
- excluded: { invalidCoordinateCount: 1 },
- })
- check(
- 'B2-distribution-three-dimensions-plus-status',
- distribution.regions.length === 1 &&
- distribution.industries[0].code === 'GAS' &&
- distribution.deviceTypes[0].code === 'GAS_SENSOR' &&
- distribution.pointStatuses.length === 6 &&
- distribution.pointStatuses[2].count === 0,
- `区域 ${distribution.regions.length} / 行业 ${distribution.industries.length} / 类型 ${distribution.deviceTypes.length} 族;状态 **6** 态全给(含 0 计数)`,
- )
- check(
- 'B2b-distribution-echoes-effective-filters',
- distribution.selectedIndustry === 'GAS' &&
- distribution.selectedPointStatus === 'ALARM' &&
- distribution.selectedRegion === '城区A',
- `生效范围回显:行业=${distribution.selectedIndustry} 状态=${distribution.selectedPointStatus} 范围=${distribution.selectedRegion}`,
- )
- // 台账与分布共用一次取数(Q4)→ 两者的分页必须来自同一份载荷。
- check(
- 'B2c-ledger-shares-distribution-pagination',
- distribution.pagination.total === 8 && distribution.pagination.hasMore === true && nextLedgerPage(distribution) === 2,
- `台账按端点分页推进:第 ${distribution.pagination.page} 页 / 共 ${distribution.pagination.total} 条 → 下一页 ${nextLedgerPage(distribution)}`,
- )
- // B3 运行概况:两条趋势**不混合**、全六态、异常清单。
- const situation = toMonitoringSituation({
- updatedAt: 'T',
- freshness: { status: 'CURRENT' },
- range: { type: 'TREND_WINDOW', startTime: 'S', endTime: 'E' },
- metricStatusDistribution: [
- { status: 'NORMAL', count: 6 },
- { status: 'WARNING', count: 1 },
- { status: 'ALARM', count: 1 },
- { status: 'EXPIRED', count: 0 },
- { status: 'OFFLINE', count: 1 },
- { status: 'UNKNOWN', count: 0 },
- ],
- warningTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 2 }],
- alarmTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 5 }],
- industryCoverage: [{ industry: 'GAS', deviceCount: 3, abnormalCount: 1, statusCounts: {} }],
- abnormalEquipment: [
- { deviceCode: 'EQ-1', deviceName: '报警设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', abnormalType: 'ALARM', status: 'ALARM', statusReasons: ['压力超高'], coordinateValidation: { renderable: true } },
- { deviceCode: 'EQ-2', deviceName: '离线设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', abnormalType: 'OFFLINE', status: 'OFFLINE', statusReasons: [], coordinateValidation: { renderable: false } },
- ],
- pagination: { page: 1, pageSize: 100, total: 2, totalPages: 1, hasMore: false },
- mapFilter: {},
- excluded: {},
- })
- check(
- 'B3-situation-keeps-warning-and-alarm-separate',
- situation.warningTrend[0].count === 2 && situation.alarmTrend[0].count === 5,
- `预警趋势 ${situation.warningTrend[0].count} 与报警趋势 ${situation.alarmTrend[0].count} **分两条**(后端注释明确两者不混合)`,
- )
- check(
- 'B3b-situation-six-metric-statuses-and-window',
- situation.metricStatuses.length === 6 &&
- situation.metricStatuses[3].count === 0 &&
- situation.rangeStart === 'S' &&
- situation.rangeEnd === 'E',
- `指标状态 6 态全给(含 0);窗口回显 ${situation.rangeStart} → ${situation.rangeEnd}`,
- )
- check(
- 'B3c-situation-abnormal-list-keeps-coordinate-state',
- situation.abnormalEquipment.length === 2 &&
- situation.abnormalEquipment[0].status === 'ALARM' &&
- situation.abnormalEquipment[1].code === 'EQ-2',
- `异常清单 ${situation.abnormalEquipment.length} 台(离线是它的子集,界面据此分开说)`,
- )
- // B4 指标状态字典:六态**全部登记**,认不出的回原码(不猜成正常)。
- check(
- 'B4-metric-status-dictionary-complete',
- ['NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN'].every((code) => METRIC_STATUS_TEXT[code] && METRIC_STATUS_TEXT[code] !== code) &&
- metricStatusText('SOMETHING_NEW') === 'SOMETHING_NEW',
- `六态全部翻成中文(${Object.values(METRIC_STATUS_TEXT).join('/')});未登记的码回原码,不猜`,
- )
- // 桶标签只做截断显示:日粒度与小时粒度都要认,解析不了**回原串**(不丢桶)。
- check(
- 'B4b-bucket-label-truncates-and-never-drops',
- bucketLabel('2026-09-02T01:00:00+08:00') === '01:00' &&
- bucketLabel('2026-09-02T01:00:00+08:00', true) === '9/2' &&
- bucketLabel('weird') === 'weird' &&
- bucketLabel('') === '',
- `小时桶 → ${bucketLabel('2026-09-02T01:00:00+08:00')};日桶 → ${bucketLabel('2026-09-02T01:00:00+08:00', true)};认不出的回原串(不静默丢数据)`,
- )
- }
- // ================================================================ C 视图层(验收点名的画面元素)
- {
- const overview = monitoringOverviewPanel(
- toMonitoringOverview({
- updatedAt: '2026-09-02T09:59:50+08:00',
- counts: { monitoringPointCount: 6, equipmentCount: 9 },
- status: { onlineCount: 8, offlineCount: 0, staleCount: 1, unknownOrMissingCount: 0, denominator: 9 },
- onlineRate: { value: '0.8889', numerator: 8, denominator: 9, state: 'AVAILABLE' },
- typeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3 }],
- freshness: { status: 'CURRENT' },
- mapFilter: {},
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const kpiLabels = overview.blocks[0].items.map((item) => item.label)
- check(
- 'C1-overview-shows-scale-rate-types-and-time',
- kpiLabels.join(',') === '监测点,监测设备,在线率,数据时间' &&
- overview.blocks[0].items[2].value === '88.9%' &&
- overview.blocks.some((b) => b.kind === 'bars' && b.label === '设备类型分布'),
- `验收四项:${kpiLabels.join(' / ')}(在线率 = ${overview.blocks[0].items[2].value},口径 ${overview.blocks[0].items[2].hint})`,
- )
- check(
- 'C1b-overview-has-no-locate-targets-and-declares-gap',
- overview.locate.length === 0 && !!overview.gap && overview.locateNote.includes('没有可定位对象'),
- '概览是聚合口径,如实说没有可定位对象;采集时间差异也如实说明(不造按钮)',
- )
- // 分母为 0 时**不能**画成 0.0%:那是票 16 明令禁止的虚假零值。
- const zeroDenominator = monitoringOverviewPanel(
- toMonitoringOverview({
- counts: { monitoringPointCount: 2, equipmentCount: 0 },
- status: { onlineCount: 0, offlineCount: 0, staleCount: 0, unknownOrMissingCount: 2, denominator: 0 },
- onlineRate: { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' },
- typeDistribution: [],
- freshness: { status: 'CURRENT' },
- mapFilter: {},
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const zeroRate = zeroDenominator.blocks[0].items.find((item) => item.key === 'onlineRate')
- check(
- 'C1c-zero-denominator-rate-is-placeholder-not-zero',
- zeroRate.value === DETAIL_PLACEHOLDER && zeroRate.hint.includes('不适用'),
- `分母为 0 时在线率显示「${zeroRate.value}」并说明「${zeroRate.hint}」——**不**画成 0.0%`,
- )
- // 分布:覆盖情况三族 + 可点状态分布 + 口径回显 + 两处缺口说明。
- const distribution = monitoringDistributionPanel(
- toMonitoringDistribution({
- regionDistribution: [{ region: '城区A', pointCount: 2, deviceCount: 1, statusCounts: {} }],
- industryDistribution: [{ industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
- deviceTypeDistribution: [{ deviceType: { code: 'TS', name: '燃气浓度计' }, industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
- pointStatusDistribution: [
- { status: 'ALARM', count: 1 },
- { status: 'WARNING', count: 0 },
- { status: 'WORK_ORDER', count: 0 },
- { status: 'NORMAL', count: 3 },
- { status: 'OFFLINE', count: 1 },
- { status: 'EMPTY', count: 1 },
- ],
- mapFilter: { industry: 'GAS', pointStatus: 'ALARM', region: '城区A' },
- pagination: { page: 1, pageSize: 500, total: 6, totalPages: 1, hasMore: false },
- monitoringPoints: [],
- unassociatedEquipment: [],
- freshness: { status: 'CURRENT' },
- excluded: { invalidCoordinateCount: 1 },
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const barLabels = distribution.blocks.filter((b) => b.kind === 'bars').map((b) => b.label)
- const statusBlock = distribution.blocks.find((b) => b.kind === 'statusFilter')
- check(
- 'C2-distribution-shows-coverage-and-status',
- barLabels.includes('覆盖情况 · 区域') &&
- barLabels.includes('覆盖情况 · 行业') &&
- barLabels.includes('覆盖情况 · 设备类型') &&
- !!statusBlock &&
- statusBlock.rows.length === 6,
- `验收「覆盖情况 + 状态分布」:${barLabels.join(' / ')};状态分布 ${statusBlock.rows.length} 行(可点)`,
- )
- // 状态词与色键必须来自票 09 那张表:`EMPTY` 在这里是「无设备」,不是新鲜度哨兵「无数据」。
- const emptyRow = statusBlock.rows.find((row) => row.code === 'EMPTY')
- check(
- 'C2b-status-words-come-from-point-status-table-not-freshness-sentinels',
- emptyRow.label === '无设备' && emptyRow.tone === 'neutral' && statusBlock.rows.find((r) => r.code === 'ALARM').label === '报警',
- `点位状态的中文来自 domain/pointStatus.js:EMPTY → 「${emptyRow.label}」**不是**新鲜度哨兵的「无数据」(两张字典合并就会把"无设备"画成"无数据")`,
- )
- check(
- 'C2c-distribution-declares-map-gap-and-region-scope',
- distribution.gap.includes('一张图') && distribution.gap.includes('范围筛选') && distribution.gap.includes(POINT_STATUS_MAP_GAP.slice(0, 10)),
- '面板如实说明:状态筛选不作用于一张图(后端缺口)+范围筛选只作用于本面板',
- )
- // 运行概况:KPI + 两条趋势 + 状态分布 + 异常清单(名单里带可定位按钮)。
- const situation = operationOverviewPanel(
- toMonitoringSituation({
- range: { type: 'TREND_WINDOW', startTime: '2026-09-01T10:00:00+08:00', endTime: '2026-09-02T10:00:00+08:00' },
- metricStatusDistribution: [
- { status: 'NORMAL', count: 6 },
- { status: 'WARNING', count: 1 },
- { status: 'ALARM', count: 1 },
- { status: 'EXPIRED', count: 0 },
- { status: 'OFFLINE', count: 2 },
- { status: 'UNKNOWN', count: 0 },
- ],
- warningTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 2 }],
- alarmTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 5 }],
- industryCoverage: [],
- abnormalEquipment: [
- { deviceCode: 'EQ-OFF', deviceName: '离线设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', status: 'OFFLINE', statusReasons: [], coordinateValidation: { renderable: true } },
- ],
- pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false },
- freshness: { status: 'CURRENT' },
- mapFilter: {},
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const trendLabels = situation.blocks.filter((b) => b.kind === 'trend').map((b) => b.label)
- check(
- 'C3-situation-shows-trends-offline-and-abnormal',
- trendLabels.join(',') === '预警趋势,报警趋势' &&
- situation.blocks.some((b) => b.kind === 'list' && b.label === '异常设备清单') &&
- situation.offlineNote.includes('离线 1 台'),
- `验收三项:事件趋势(${trendLabels.join(' / ')})、离线设备(${situation.offlineNote})、异常态势(状态分布 + 清单)`,
- )
- check(
- 'C3b-situation-range-note-reflects-backend-window',
- situation.rangeNote.includes('2026-09-01T10:00:00+08:00') && situation.rangeNote.includes('2026-09-02T10:00:00+08:00'),
- `窗口口径回显后端实际生效区间:${situation.rangeNote}`,
- )
- // 异常清单里的可定位对象与 `locate` 必须**同源**(不漏一个设备按钮)。
- check(
- 'C3c-situation-abnormal-entries-are-locatable',
- situation.locate.length === 1 && situation.locate[0].pointCode === 'EQ-OFF',
- `异常设备可定位(${situation.locate.map((e) => e.pointCode).join(', ')})`,
- )
- // 空场景与未取数:措辞分得开(票 16/17 的纪律)。
- check(
- 'C4-placeholders-per-state',
- monitoringOverviewPanel(null, section(SECTION_STATE.EMPTY)).placeholder === '该面板当前没有数据' &&
- monitoringOverviewPanel(null, section(SECTION_STATE.FAILED)).placeholder.includes('加载失败') &&
- monitoringOverviewPanel(null, section(SECTION_STATE.FAILED)).placeholder.includes('其它面板') &&
- monitoringOverviewPanel(null, { state: SECTION_STATE.EMPTY, loaded: false }).placeholder === '正在取数…',
- '空 / 失败 / 未取数三种措辞分得开(失败不牵连其它面板;未取数不说"没有数据")',
- )
- // 「进度」也要如实:台账的 pageNote 必须点明 total 含未关联设备,否则会被读成"还有些点没列出来"。
- const ledger = monitoringPointLedgerPanel(
- toMonitoringDistribution({
- pagination: { page: 1, pageSize: 3, total: 8, totalPages: 3, hasMore: true },
- monitoringPoints: [
- { pointCode: 'MP-001', pointName: '供水干管监测点', region: '城区B', industry: 'WATER_SUPPLY', deviceType: { code: 'T', name: '供水压力计' }, pointStatus: 'NORMAL', statusReasons: [] },
- ],
- unassociatedEquipment: [],
- freshness: { status: 'CURRENT' },
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- {},
- )
- check(
- 'C5-ledger-page-note-explains-total-includes-equipment',
- ledger.pageNote.includes('含未关联设备') && ledger.hasMore === true,
- `分页口径说清楚:${ledger.pageNote}`,
- )
- }
- // ================================================================ D 分区独立(三块各自成败)
- {
- const store = useMonitoringStore()
- store.reset()
- store.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await store.loadPanels({ industry: 'all' })
- const all = store.sections
- check(
- 'D1-all-four-panels-load',
- MONITORING_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS && all[id].loaded === true),
- MONITORING_PANEL_IDS.map((id) => `${id}=${all[id].state}`).join(' '),
- )
- // 只让**一个**端点失败:其余两块照常成功(票 11/17 的同一条纪律)。
- const isolated = useMonitoringStore()
- isolated.reset()
- isolated.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'failed',
- onlyPath: '/monitoring/overview',
- handlers: { registerMockTransport, resetMockTransportsForTest },
- })
- await isolated.loadPanels({ industry: 'all' })
- const sections = isolated.sections
- check(
- 'D2-one-panel-failure-does-not-cover-others',
- sections.monitoringOverview.state !== SECTION_STATE.SUCCESS &&
- sections.monitoringDistribution.state === SECTION_STATE.SUCCESS &&
- sections.operationOverview.state === SECTION_STATE.SUCCESS,
- `概览块=${sections.monitoringOverview.state},其余两块仍是 ${sections.monitoringDistribution.state}/${sections.operationOverview.state}`,
- )
- check(
- 'D3-guard-has-teeth',
- MONITORING_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS),
- '全成功场景四块全绿(因此 D2 的"一块坏"是有信息量的,不是恒真)',
- )
- // 空场景:三块都说"没有数据",不伪造 0。
- const emptyStore = useMonitoringStore()
- emptyStore.reset()
- emptyStore.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await emptyStore.loadPanels({ industry: 'all' })
- const emptyViews = MONITORING_PANEL_IDS.map((id) => monitoringPanelView(id, emptyStore.sections[id].data, emptyStore.sections[id], {}))
- check(
- 'D4-empty-scenario-all-placeholders',
- emptyViews.every((view) => view.placeholder === '该面板当前没有数据') &&
- emptyViews.every((view) => !view.blocks.some((b) => (b.items || []).some((i) => i.value === '0'))),
- `空场景四块都是明确占位说明,且不显示伪造的 0 读数`,
- )
- // 过期场景:内容照画 + 保留标识(票 16 的立场),不是"没有数据"。
- const staleStore = useMonitoringStore()
- staleStore.reset()
- staleStore.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await staleStore.loadPanels({ industry: 'all' })
- const staleOverview = staleStore.sections.monitoringOverview
- check(
- 'D5-stale-is-value-not-empty',
- staleOverview.state === SECTION_STATE.STALE &&
- staleOverview.retained === true &&
- monitoringPanelView('monitoringOverview', staleOverview.data, staleOverview).hasContent === true,
- `过期分区仍带着数据(监测点 ${staleOverview.data.monitoringPointCount}),界面照画并标过期`,
- )
- }
- // ================================================================ E 筛选联动(映射与白名单)
- {
- check(
- 'E1-industry-key-maps-to-backend-code',
- industryQuery('gas') === 'GAS' &&
- industryQuery('water') === 'WATER_SUPPLY' &&
- industryQuery('drainage') === 'DRAINAGE' &&
- // 票 18 更正(2026-09-28):`MANHOLE` 现已是后端第四个正式行业(全部 11 个 cockpit
- // 服务都收,含 `map-snapshot`),所以井盖**要下发** —— 原断言守的
- // 「井盖无契约位」已被后端推翻(那是票 17 立、票 25 第八轮记的旧事实)。
- industryQuery('manhole') === 'MANHOLE',
- `gas → ${industryQuery('gas')};water → ${industryQuery('water')};drainage → ${industryQuery('drainage')};manhole → ${industryQuery('manhole')}(四个正式行业全部下发)`,
- )
- check(
- 'E2-only-all-and-unknown-keys-send-nothing',
- industryQuery('all') === '' && industryQuery('nope') === '' && industryQuery(undefined) === '',
- '「全部」不传参;**认不出的键**也不传参(兜底:将来新增的前端选项若后端还没有这个码,不拿脏值去换 400)',
- )
- // 状态白名单:认不出的**不传参**(后端对认不出的状态直接 400,那会把一次点击变成一条报错)。
- check(
- 'E3-status-whitelist-blocks-unknown-codes',
- statusParam('ALARM') === 'ALARM' &&
- statusParam('alarm') === 'ALARM' &&
- statusParam('MAINTENANCE') === '' &&
- statusParam('') === '' &&
- isKnownPointStatus('ALARM') &&
- !isKnownPointStatus('MAINTENANCE'),
- `认得出的六态才下发(ALARM → ${statusParam('ALARM')});认不出的回空串=不传参(MAINTENANCE → "${statusParam('MAINTENANCE')}")`,
- )
- // 范围映射:**默认空**(后端 region 是自由串、无枚举可依据),配了才下发。
- check(
- 'E4-region-mapping-is-config-driven-and-empty-by-default',
- regionQuery('south', {}) === '' &&
- regionQuery('all', { south: '城区A' }) === '' &&
- regionQuery('south', { south: '城区A' }) === '城区A' &&
- hasRegionMapping('south', { south: '城区A' }) &&
- !hasRegionMapping('south', {}) &&
- REGION_MAP_GAP.includes('regionMap'),
- `未配置 → 不传参("${regionQuery('south', {})}")并在界面标缺口;配置后 → ${regionQuery('south', { south: '城区A' })};全域恒不传参`,
- )
- // 空区域是真实取值:配置里写「未分区」要能筛到(后端把空归一成这个字面量)。
- check(
- 'E4b-unassigned-region-is-a-real-filterable-value',
- regionQuery('north', { north: '未分区' }) === '未分区',
- '「未分区」在后端是空值归一后的**真实取值**,不是空值 —— 筛选它能筛到东西',
- )
- // **端到端:配置真的把映射送到**(票 18 自查补的断言)。
- //
- // 这一条是补一个**真缺陷**:`map.filters.regionMap` 原本只被外壳读过,而配置要先过
- // `normalizeConfig` 归一化 —— 归一化器不认这个键,于是外壳永远读到 `{}`,
- // **范围筛选端到端从不生效**。而 E4 只单独测了 `regionQuery()` 并**手喂**映射表,
- // 所以它当时全绿却什么也没证明(守卫比它要守的东西宽:测了函数、没测通路)。
- //
- // 判据必须**从原始配置载荷出发**(而不是手搓一个 regionMap 对象),
- // 否则测的还是同一个盲区。
- {
- const rawConfig = { dataSource: 'mock', map: { filters: { regionMap: { south: '城区A', north: '未分区' } } } }
- const normalized = normalizeConfig(rawConfig)
- const delivered = (normalized.config.map.filters && normalized.config.map.filters.regionMap) || {}
- check(
- 'E4c-config-really-delivers-the-region-map',
- delivered.south === '城区A' &&
- delivered.north === '未分区' &&
- regionQuery('south', delivered) === '城区A' &&
- // 没配的部署:默认空表,范围条件不下发(不猜映射)。
- Object.keys((normalizeConfig({ dataSource: 'mock' }).config.map.filters || {}).regionMap || {}).length === 0,
- `原始配置经归一化后送到外壳:${JSON.stringify(delivered)};未配置时是空表(范围不下发)`,
- )
- // 脏值逐项丢弃、**不清空整表**(一个拼错的键不该静默废掉其它正确的映射)。
- //
- // 判据要同时盖住**坏值**(数字/空白)与**坏键**(空串):
- // 只查 `length === 1` 会漏掉"坏键被原样留下"这一种(变异 M22 确认过:
- // 把 `regionMap[key] = value` 换成放原始 `scopeKey`/`regionValue`,闸门仍全绿)。
- const dirty = normalizeConfig({
- dataSource: 'mock',
- map: { filters: { regionMap: { south: '城区A', north: 123, taichang: ' ', '': 'X' } } },
- })
- const dirtyMap = dirty.config.map.filters.regionMap
- check(
- 'E4d-bad-region-map-entries-are-dropped-individually',
- dirtyMap.south === '城区A' &&
- Object.keys(dirtyMap).length === 1 &&
- // 坏键不得以任何形式留下(既不能是空串,也不能原样留着 `north`/`taichang`)。
- !Object.prototype.hasOwnProperty.call(dirtyMap, '') &&
- !Object.prototype.hasOwnProperty.call(dirtyMap, 'north') &&
- !Object.prototype.hasOwnProperty.call(dirtyMap, 'taichang') &&
- // 值必须已被 trim(配置里写 `' 城区A '` 也要能对上后端的精确串)。
- dirty.issues.length === 3,
- `坏项逐条丢弃、好项保留:${JSON.stringify(dirtyMap)}(记了 ${dirty.issues.length} 条 issue)`,
- )
- // 值的 trim 单独钉一条:范围是**精确匹配**的(后端 `matchesRegion` 用 `equals`),
- // 首尾空格会让映射永远匹配不上 —— 那是"配了却筛不出"的静默失败。
- const spaced = normalizeConfig({ dataSource: 'mock', map: { filters: { regionMap: { south: ' 城区A ' } } } })
- check(
- 'E4e-region-map-values-are-trimmed',
- spaced.config.map.filters.regionMap.south === '城区A' &&
- regionQuery('south', spaced.config.map.filters.regionMap) === '城区A',
- `配置里写 ' 城区A ' → 下发 "${spaced.config.map.filters.regionMap.south}"(后端是精确匹配,空格会让它永远筛不出)`,
- )
- }
- // 端到端:三个筛选**真的进请求串**(只看映射函数不够,得看请求本身)。
- const seen = []
- registerMockTransport('/monitoring/distribution', (context) => {
- seen.push(context.path)
- return { monitoringPoints: [], unassociatedEquipment: [], pagination: { page: 1, pageSize: 500, total: 0, hasMore: false }, freshness: { status: 'EMPTY' }, regionDistribution: [], industryDistribution: [], deviceTypeDistribution: [], pointStatusDistribution: [], mapFilter: {}, excluded: {} }
- })
- const store = useMonitoringStore()
- store.reset()
- store.configure({ config: mockConfig, regionMap: { south: '城区A' } })
- await store.loadPanels({ industry: 'gas', pointStatus: 'ALARM', region: 'south' })
- const path = seen[seen.length - 1] || ''
- check(
- 'E5-three-filters-reach-the-request',
- path.includes('industry=GAS') && path.includes('pointStatus=ALARM') && path.includes('region=%E5%9F%8E%E5%8C%BAA'),
- `请求串:${path}`,
- )
- // 对抗式反证:默认筛选下**三个参数一个都不该出现**(否则上面那条可能是恒真)。
- await store.loadPanels({ industry: 'all', pointStatus: '', region: 'all' })
- const plain = seen[seen.length - 1] || ''
- check(
- 'E6-default-filters-send-nothing',
- !plain.includes('industry=') && !plain.includes('pointStatus=') && !plain.includes('region='),
- `默认筛选下请求串零筛选参数:${plain}`,
- )
- // filterStore 的状态是**可清除**的(没有"全部"按钮可退回,所以再点一次要能取消)。
- const filters = useFilterStore()
- filters.reset()
- filters.togglePointStatus('ALARM')
- const toggledOn = filters.pointStatus
- filters.togglePointStatus('ALARM')
- const toggledOff = filters.pointStatus
- filters.setPointStatus('NOPE')
- const rejected = filters.pointStatus
- check(
- 'E7-point-status-is-removable-and-rejects-unknown',
- toggledOn === 'ALARM' && toggledOff === DEFAULT_POINT_STATUS && rejected === DEFAULT_POINT_STATUS,
- `点一次 → ${toggledOn};再点一次 → "${toggledOff}"(退回全部);脏值被忽略 → "${rejected}"`,
- )
- }
- // ================================================================ F 地图联动(筛选真的下发且真的生效)
- {
- // F0 **面板数字随筛选变化**:这一条是**真机走查补上来的**。
- // 逻辑闸门原来只验到"筛选进了请求串""映射函数对不对",而 mock 忽略筛选时
- // 请求串照样正确、断言照样全绿 —— 但界面上数字纹丝不动(走查 D1 正是这样红的)。
- // 所以这里断言的是**端到端的结果**:换行业后概览的点位/设备数必须真的变。
- {
- const all = await fetchMonitoringOverview({ industry: 'all', config: mockConfig })
- const gas = await fetchMonitoringOverview({ industry: 'gas', config: mockConfig })
- const drain = await fetchMonitoringOverview({ industry: 'drainage', config: mockConfig })
- const manhole = await fetchMonitoringOverview({ industry: 'manhole', config: mockConfig })
- check(
- 'F0-panel-numbers-follow-the-industry-filter',
- // 全部 = 3+3+3+2 = 11 台(票 18 更正后样例含井盖行业)。
- all.data.equipmentCount === 11 &&
- gas.data.equipmentCount === 3 &&
- drain.data.equipmentCount === 3 &&
- manhole.data.equipmentCount === 2 &&
- gas.data.monitoringPointCount === 2 &&
- manhole.data.monitoringPointCount === 1 &&
- gas.data.typeDistribution.length === 1 &&
- gas.data.typeDistribution[0].industry === 'GAS' &&
- gas.data.equipmentCount !== all.data.equipmentCount,
- `全部 ${all.data.monitoringPointCount} 点/${all.data.equipmentCount} 台 → 燃气 ${gas.data.monitoringPointCount} 点/${gas.data.equipmentCount} 台、排水 ${drain.data.equipmentCount} 台、井盖 ${manhole.data.monitoringPointCount} 点/${manhole.data.equipmentCount} 台(面板数字真的跟着筛选走,不只是请求串带着参数)`,
- )
- // 在线率也要跟着筛选重算,而不是照抄一份固定值。
- check(
- 'F0b-online-rate-is-recomputed-per-filter',
- all.data.onlineRate !== null && gas.data.onlineRate !== null && all.data.onlineRate !== gas.data.onlineRate,
- `全部在线率 ${all.data.onlineRate} ≠ 燃气在线率 ${gas.data.onlineRate}(按筛选后的分母重算)`,
- )
- }
- // F0c 台账的**现场页大小覆盖**真的生效:后端默认 500,样例 9 条会一页到底,
- // 那样「渐进加载」在真机上永远演示不出来(走查 B1 靠它)。
- {
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'success',
- monitoring: { pageSize: 3 },
- handlers: { registerMockTransport, resetMockTransportsForTest },
- })
- const paged = await fetchMonitoringDistribution({ industry: 'all', config: mockConfig })
- // 样例是 7 个监测点 + 2 台未关联设备 = 9 条(一条总序列,与后端分页窗口同语义)。
- check(
- 'F0c-ledger-page-size-override-takes-effect',
- paged.data.pagination.pageSize === 3 &&
- paged.data.points.length === 3 &&
- paged.data.pagination.hasMore === true &&
- paged.data.pagination.total === 9,
- `现场页大小 3 生效:第 1 页拿到 ${paged.data.points.length} 个点位、共 ${paged.data.pagination.total} 条、还有下一页=${paged.data.pagination.hasMore}`,
- )
- }
- // F0d 井盖(`MANHOLE`)**真的能筛出东西**:它是后端第四个正式行业,
- // 样例必须带该类数据 —— 不带的话界面上永远筛出空,看起来像坏了。
- {
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const manhole = await fetchMonitoringDistribution({ industry: 'manhole', config: mockConfig })
- const overviewManhole = await fetchMonitoringOverview({ industry: 'manhole', config: mockConfig })
- // 防御式取值:样例若被删掉,`isEmpty` 会把分区收成 `EMPTY`(`data` 为 null)——
- // 那时这条断言必须**干净地判失败**,而不是抛 "Cannot read properties of null"
- // (抛异常虽然也是非零退出,但那证明不了守卫抓到了行为差异;
- // 本票已两次栽在"把崩溃当成红"上)。
- const points = (manhole.data && manhole.data.points) || []
- const overviewTypes = (overviewManhole.data && overviewManhole.data.typeDistribution) || []
- check(
- 'F0d-manhole-industry-filters-real-data',
- !!manhole.data &&
- manhole.data.selectedIndustry === 'MANHOLE' &&
- points.length > 0 &&
- points.every((point) => point.industry === 'MANHOLE') &&
- !!overviewManhole.data &&
- overviewManhole.data.equipmentCount === 2 &&
- overviewTypes.length === 1,
- `井盖筛选真的筛出 ${points.length} 个点位(${points.map((p) => p.code).join(',')})、概览 ${overviewManhole.data ? overviewManhole.data.equipmentCount : '(分区为空)'} 台设备(不再是「无契约位故不传参」)`,
- )
- }
- // F1 行业筛选**真的改变地图内容**。判据不能只看请求串 —— mock 已经照后端语义真的过滤,
- // 所以这里断言「上了图的点位集合确实变小了」,那才证明筛选生效(不是假绿)。
- const mapStore = useMapStore()
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: mockConfig, industry: 'gas' })
- await mapStore.advance()
- await mapStore.advance()
- await mapStore.advance()
- const gasCodes = mapStore.summary.objects.map((item) => item.code).sort()
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: mockConfig })
- await mapStore.advance()
- await mapStore.advance()
- await mapStore.advance()
- const allCodes = mapStore.summary.objects.map((item) => item.code).sort()
- check(
- 'F1-industry-filter-actually-changes-the-map',
- gasCodes.length > 0 && gasCodes.length < allCodes.length && gasCodes.every((code) => allCodes.includes(code)),
- `燃气筛选后上图 ${gasCodes.join(',')};不筛时 ${allCodes.join(',')}(筛选真的作用到地图内容,不是只在请求串里)`,
- )
- // F2 范围筛选(配了映射才生效)同样真的改变地图内容;未配置时**不改变**任何东西。
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: mockConfig, region: 'south', regionMap: { south: '城区A' } })
- await mapStore.advance()
- await mapStore.advance()
- await mapStore.advance()
- const regionCodes = mapStore.summary.objects.map((item) => item.code).sort()
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: mockConfig, region: 'south', regionMap: {} })
- await mapStore.advance()
- await mapStore.advance()
- await mapStore.advance()
- const unmappedCodes = mapStore.summary.objects.map((item) => item.code).sort()
- check(
- 'F2-region-filter-only-bites-when-mapped',
- regionCodes.length > 0 && regionCodes.length < allCodes.length && unmappedCodes.length === allCodes.length,
- `配了映射 → ${regionCodes.join(',')}(变小);没配映射 → ${unmappedCodes.length} 个(与不筛一致,因为根本没下发 region 参数)`,
- )
- // F3 换筛选=**换新快照重载**(Q9):已加载对象被丢弃,不留上一份范围的点。
- mapStore.reset()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- mapStore.setup({ config: mockConfig })
- await mapStore.advance()
- await mapStore.advance()
- await mapStore.advance()
- const beforeSwitch = mapStore.summary.objects.length
- await mapStore.applyFilters({ industry: 'gas' })
- const afterSwitch = mapStore.summary.objects.map((item) => item.code)
- check(
- 'F3-filter-change-reloads-from-page-1-and-drops-old-objects',
- beforeSwitch === 5 && afterSwitch.length === 1 && afterSwitch[0] === 'MP-002' && mapStore.summary.page === 1,
- `换行业前 ${beforeSwitch} 个对象 → 换后 ${afterSwitch.length} 个、回到第 ${mapStore.summary.page} 页(旧范围的点不留在图上)`,
- )
- // F4 筛选条件被**合并**进注入项,不被后到的 setup 抹掉(否则第一屏会带空筛选发出去)。
- const merged = useMapStore()
- merged.reset()
- merged.setup({ config: mockConfig, industry: 'gas' })
- merged.setup({ config: mockConfig })
- check(
- 'F4-setup-merges-instead-of-replacing-filters',
- merged.filters.industry === 'gas',
- `后到的 setup() 不抹掉先到的筛选条件(industry 仍是 ${merged.filters.industry})`,
- )
- // F5 状态筛选**不**下发到地图:`map-snapshot` 不收该入参(后端契约现状,缺口已记票 25)。
- const mapCode = readCode('services/mapApi.js')
- check(
- 'F5-status-filter-is-not-sent-to-map-snapshot',
- !/pointStatus/.test(mapCode) && POINT_STATUS_MAP_GAP.includes('map-snapshot'),
- '地图取数不出现状态入参(后端不收);缺口写成常量由界面说明,而不是假装跟随',
- )
- }
- // ================================================================ G 台账(共用取数 + 渐进加载 + 徽标)
- {
- const store = useMonitoringStore()
- store.reset()
- store.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await store.loadPanels({ industry: 'all', pageSize: 3 })
- const distribution = store.sections.monitoringDistribution
- const ledger = store.sections.monitoringPointLedger
- // G1 共用一次取数:两块的分区来自**同一个响应对象**(Q4 裁定)。
- check(
- 'G1-ledger-and-distribution-share-one-fetch',
- ledger.data === distribution.data && ledger.data.points.length === distribution.data.points.length,
- `台账与分布的分区引用同一份载荷(${ledger.data.points.length} 个点位、共 ${ledger.data.pagination.total} 条),不是两次请求`,
- )
- // G2 渐进加载:翻下一页后点位**追加**,且总数口径不变。
- // 注意每次都要**重新读 store**:`sections` 是整体替换的 ref,抓在手里的旧对象不会跟着变
- // (第一版就是抓着翻页前的对象比,翻页后它当然还是 3 —— 那是脚本自己的错,不是实现的错)。
- const firstCount = store.sections.monitoringPointLedger.data.points.length
- const merged = await store.loadMoreLedger()
- check(
- 'G2-ledger-appends-next-page',
- merged.data.points.length > firstCount &&
- store.sections.monitoringPointLedger.data.points.length === merged.data.points.length &&
- merged.data.pagination.total === distribution.data.pagination.total,
- `翻页前 ${firstCount} 个 → 翻页后 ${merged.data.points.length} 个;总数仍为 ${merged.data.pagination.total}(追加而不是替换)`,
- )
- // G3 到底之后再翻是空操作(不无限发请求)。
- //
- // 判据用**分页终态**,不用"点数又涨了":分页窗口横跨「监测点 + 未关联设备」一条总序列,
- // 所以**最后一页可能整页都是设备**(点数一个不涨)—— 拿点数比会把一个正常的到底误判成失败
- // (第一版就是这么错的:样例 9 条、页大小 3,第 3 页恰好是 1 点 + 2 设备)。
- //
- // 也不能用**对象同一性**比"再调一次没变":pinia 的 `sections` 是响应式代理,
- // 每次读回来的都是新代理对象,`again === settled` 恒假(与 G4 徽标同一个坑)。
- const settled = await store.loadMoreLedger()
- const again = await store.loadMoreLedger()
- check(
- 'G3-ledger-stops-at-the-end',
- nextLedgerPage(settled.data) === null &&
- settled.data.pagination.hasMore === false &&
- again.data.points.length === settled.data.points.length &&
- again.data.pagination.page === settled.data.pagination.page,
- `翻到底后不再发请求(nextLedgerPage → ${nextLedgerPage(settled.data)}、hasMore=${settled.data.pagination.hasMore});再调一次页码与点数都不动(仍停在第 ${again.data.pagination.page} 页 / ${again.data.points.length} 个点位)`,
- )
- // G4 徽标按需取、各自成败、**取不到不给 0**。
- // 注意 `store.badges` 是 pinia 的响应式代理,取回来的是 **proxy** 而非原对象 ——
- // 所以断言比**字段值**,不比引用(第一版比引用,恒假)。
- const badge = await store.loadLedgerBadge('MP-001')
- const stored = store.badges['MP-001']
- check(
- 'G4-badge-loads-per-point-on-demand',
- badge.state === 'success' &&
- badge.total === 2 &&
- stored &&
- stored.state === 'success' &&
- stored.total === badge.total &&
- stored.alarm === badge.alarm,
- `MP-001 徽标:设备 ${badge.total} · 报警 ${badge.alarm}(逐点位取;已写进 store,字段与返回值一致)`,
- )
- check(
- 'G4b-badge-not-refetched',
- shouldLoadBadge(store.badges, 'MP-001') === false && shouldLoadBadge(store.badges, 'MP-999') === true,
- '已取过的点位不再发请求(卡片滚动时不反复拉);没取过的仍会取',
- )
- // 徽标失败隔离:只让**那一个点位**的详情端点坏,徽标记 failed 且**不给 0**。
- // `onlyPath` 比的是**注册模板**(`path === onlyPath`),带路径参数的接口模板里是 `*` ——
- // 写成解析后的真实路径匹配不上,异常样例根本不会生效(那会让这条断言变成假绿)。
- const failed = useMonitoringStore()
- failed.reset()
- failed.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'failed', onlyPath: '/monitoring-points/*', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const failedBadge = await failed.loadLedgerBadge('MP-001')
- check(
- 'G5-badge-failure-does-not-fake-zero',
- failedBadge.state !== 'success' && failedBadge.total === null,
- `徽标取不到时状态=${failedBadge.state}、值=${failedBadge.total}(**不是** 0 —— 0 会读成"确实没有设备")`,
- )
- // 对抗式反证:同样的判据在正常场景下必须拿到**真值**,否则上面那条可能只是"永远失败"。
- check(
- 'G5b-badge-guard-has-teeth',
- badge.state === 'success' && badge.total === 2,
- '同一判据在正常场景拿到真实计数(因此 G5 的"取不到"是有信息量的)',
- )
- // 徽标画不出来时组件说"取数中",而不是画一个 0。
- const viewWithPending = monitoringPointLedgerPanel(
- { points: [{ pointCode: 'MP-001', pointName: 'P', region: '城区A', industry: 'GAS', deviceType: null, pointStatus: 'NORMAL', statusReasons: [] }], pagination: { page: 1, hasMore: false } },
- section(SECTION_STATE.SUCCESS),
- {},
- )
- check(
- 'G6-pending-badge-is-not-zero',
- viewWithPending.blocks[0].rows[0].badge === null && viewWithPending.blocks[0].rows[0].badgePending === true,
- '徽标还没取到时 `badge` 为 null(组件画"取数中"),不画 0',
- )
- }
- // ================================================================ H 静态守卫
- {
- const panelVue = readVueCode('components/PanelBlocks.vue')
- const shellVue = readVueCode('CockpitShell.vue')
- const viewCode = readCode('domain/monitoringPanels.js')
- const blocksCode = readCode('domain/panelBlocks.js')
- const registryCode = readCode('domain/panelViews.js')
- const storeCode = readCode('stores/monitoringStore.js')
- const apiCode = readCode('services/monitoringApi.js')
- check(
- 'H1-renderer-has-no-api-paths-or-fetch',
- !/\/monitoring\//.test(panelVue) && !/\/infrastructure\//.test(panelVue) && !/fetch\(/.test(panelVue),
- '接口路径只在 services/*Api.js,共享渲染层不自己拼',
- )
- // 渲染层**不认识 panelId**:分发归注册表,它只按 block.kind 分派。
- // 这条是本票解除模式耦合的落点 —— 组件若又按 panelId 分支,票 19/20 就得再改它一次。
- check(
- 'H2-renderer-does-not-know-panel-ids',
- !/monitoringOverview|monitoringDistribution|operationOverview|monitoringPointLedger|archive|emergency/.test(panelVue) &&
- !/panelId/.test(panelVue),
- '共享渲染层里没有任何 panelId(吃算好的 view prop),因此票 19/20 接进来不用改它',
- )
- check(
- 'H3-renderer-has-no-backend-field-names',
- !/monitoringPointCount|onlineRate|typeDistribution|pointStatusDistribution|abnormalEquipment|warningTrend|bucketStart|monitoringPoints|industryDistribution/.test(panelVue) &&
- !/monitoringPointCount|pointStatusDistribution/.test(shellVue),
- '渲染层只读视图对象字段(后端裸字段名只出现在 domain 适配层)',
- )
- // 后端原因码/枚举值不许出现在渲染层(票 05/11/17 各逮到过一次同类泄漏)。
- const leaks = ['READY', 'NO_RESOLVED_DEVICES', 'AVAILABLE', 'TREND_WINDOW', 'CURRENT_BASELINE', 'GAS_SENSOR', 'SMART_DEVICE', 'NORMAL', 'OFFLINE', 'EXPIRED']
- const leaked = leaks.filter((token) => new RegExp(`\\b${token}\\b`).test(panelVue))
- check(
- 'H4-no-backend-codes-in-renderer',
- leaked.length === 0,
- leaked.length ? `渲染层出现后端代号:${leaked.join(', ')}` : '渲染层零后端代号(都是 domain 翻好的人话)',
- )
- check(
- 'H5-domain-is-pure-and-does-not-import-service',
- !/\bfetch\s*\(|XMLHttpRequest/.test(viewCode) &&
- !/from '\.\.\/services\//.test(viewCode) &&
- !/\bfetch\s*\(|XMLHttpRequest/.test(blocksCode) &&
- !/from '\.\.\/services\//.test(blocksCode) &&
- !/\bfetch\s*\(/.test(registryCode),
- 'domain 三个新模块都是纯函数层(不发请求、不 import service)',
- )
- check(
- 'H6-store-has-no-raw-fields-or-paths',
- !/\/monitoring\//.test(storeCode) && !/monitoringPoints|pointStatusDistribution|abnormalEquipment|onlineRate/.test(storeCode),
- 'store 只转发分区,不改名也不拼路径(裸字段名一个都不出现)',
- )
- check(
- 'H7-api-has-no-credential-and-no-write',
- !/Authorization|Bearer|api\.token/.test(apiCode) && !/method:\s*'(POST|PUT|PATCH|DELETE)'/.test(apiCode),
- '凭据只在 services/cockpitApi.js 附加(票 04 的接缝未被绕过);本票不引入写操作',
- )
- // 外壳必须把四块走**同一个**接缝,且不与具体模式耦合。
- check(
- 'H8-shell-routes-panels-through-the-shared-seam',
- /panelViewOf\(panelId\)/.test(shellVue) &&
- /PanelBlocks/.test(shellVue) &&
- /PanelPlaceholder v-else/.test(shellVue) &&
- !/InfrastructurePanel/.test(shellVue),
- '外壳按 panelId 问注册表要视图,一块组件渲染全部已接数据的面板;未接的走占位',
- )
- // 「已实现但还没取数」不得被判成「还没实现」:走组件还是走占位的判据必须是
- // `hasPanelView(panelId)`(注册表成员资格),**不能**是"视图算出来没有" ——
- // 后者在一个已接数据的面板还在加载时也为空,于是界面会印出「面板占位 · panelId」,
- // 那是在说"这块还没做"(本票自查时真的这么错过一次)。
- // 票 38 修正:原来钉的是精确串 `v-if="hasPanelView(panelId)"`,但票 19 在同一行尾追加了
- // 「除事件族外」的路由条件(`&& ![...,'eventAnalysis'].includes(panelId)`)——
- // **那是合法改动**,却让这条精确串匹配失效(断言测的是写法而不是不变式)。
- // 要守的不变式是:判据仍然以**注册表成员资格**开头(两栏各一处),且没退化成"视图算出来没有"。
- // 所以改成前缀匹配,写法可以演进、不变式不被放过。
- check(
- 'H9-placeholder-vs-pending-is-decided-by-registry-not-by-view-presence',
- (shellVue.match(/v-if="hasPanelView\(panelId\)/g) || []).length === 2 &&
- !/v-if="panelViewOf\(panelId\)"/.test(shellVue) &&
- /!hasPanelView\(panelId\)\) return '面板占位'/.test(shellVue.replace(/\s+/g, ' ')) &&
- /return '正在取数'/.test(shellVue),
- '「未实现」判据是注册表成员资格(两栏各一处);已实现但未取数显示「正在取数」而不是「面板占位」',
- )
- // 筛选必须**真的作用到数据上**,而不只是记在 store 里(CONTEXT.md 的全局条件)。
- check(
- 'H10-shell-follows-all-three-filters',
- /watch\(industry,/.test(shellVue) &&
- /watch\(scope,/.test(shellVue) &&
- /watch\(pointStatus,/.test(shellVue) &&
- /monitoringStore\.loadPanels\(/.test(shellVue) &&
- /refreshMapForFilters/.test(shellVue),
- '行业/范围/状态三个筛选变化都真的重取面板;行业与范围还换新快照重载地图',
- )
- // 状态筛选**不得**触发地图重载(后端不收 pointStatus,跟随就是空转一次请求而画面不变)。
- // 判据是**只截 watch(pointStatus) 自己的函数体**再查它有没有调 refreshMapForFilters ——
- // 第一版用固定字符窗口,把紧跟在后面的 refreshMapForFilters 函数定义也框进来了,是假红。
- const statusWatch = (/watch\(pointStatus,[\s\S]*?\n\}\)/.exec(shellVue) || [''])[0]
- const scopeWatch = (/watch\(scope,[\s\S]*?\n\}\)/.exec(shellVue) || [''])[0]
- check(
- 'H11-status-watch-does-not-reload-the-map',
- statusWatch.includes('monitoringStore.loadPanels({ pointStatus: next })') &&
- !statusWatch.includes('refreshMapForFilters') &&
- scopeWatch.includes('refreshMapForFilters'),
- `状态筛选的 watch 体只重取面板、不碰地图;而范围筛选的 watch 体**确实**调 refreshMapForFilters(对照组,证明判据有牙)`,
- )
- // 台账卡片必须走**与地图相同**的监测点选择入口,不另立一条路径。
- //
- // **断言在票 41 被改写**(成例:t01 的 A5 在票 17 被改写)。原文要求函数体里出现
- // `detailStore.openPoint(pointCode)` 且**不移动相机** —— 那是票 18 当时的语义;
- // 票 41 验收第 1、2 条改为「地图点击与台账选择使用**同一**监测点选择入口」且
- // 「有效点位坐标触发点位级完整视角定位」。于是判据换成:
- // ① 台账函数体调统一入口 `selectMonitoringPoint`;
- // ② 它**不再**自己碰相机(定位归那个统一入口,台账不另写第二条相机路径);
- // ③ 对照组:`locateCode`(异常设备联动)**仍然**用自己那套 `setCameraFocus` ——
- // 证明这条判据不是"全文没有 setCameraFocus 就绿"。
- //
- // 判据仍然**只截 `openLedgerPoint` 自己的函数体**:对整个文件做正则会被别处满足
- // (本票第二次栽在"守卫比它要守的东西宽"上,见票 18 的记录)。
- const ledgerBody = (/function openLedgerPoint[\s\S]*?\n \}/.exec(storeCode) || [''])[0]
- const locateBody = (/function locateCode[\s\S]*?\n \}/.exec(storeCode) || [''])[0]
- check(
- 'H12-ledger-cards-reuse-the-two-level-detail-entry',
- ledgerBody.includes('selectMonitoringPoint(pointCode)') &&
- !ledgerBody.includes('setCameraFocus') &&
- locateBody.includes('setCameraFocus'),
- '台账卡片走**统一选择入口**(票 41 改写:不再是不动相机的旁路),且不在台账里另写相机路径'
- + `(对照:异常设备联动的 locateCode 仍用 setCameraFocus=${locateBody.includes('setCameraFocus')})`,
- )
- // 徽标是**按需**取的(Q14:卡片进视口才拉),所以 store 里的 `loadLedgerBadge` 必须真的
- // 被界面上某处触发 —— 否则它是一段死代码,界面永远停在「设备统计取数中…」。
- // 真机走查第一轮正是这样红的(B2):store 有函数、视图有徽标位,**没人调**。
- //
- // 判据必须**只截 IntersectionObserver 的回调体**:组件里有**两处** emit `badge-needed`
- // —— 观察器回调(真正的按需路径)与 `IntersectionObserver` 不存在时的**退化全要**分支。
- // 对整个文件做正则会被退化分支满足,于是"把按需触发拿掉"照样全绿
- // (变异 M16 确认过:拿掉观察器里那次 emit,闸门仍是 67/67)。
- // 这是本票**第三次**栽在"守卫比它要守的东西宽"上 —— 三处都已按函数体/回调体收窄。
- const badgeVue = readVueCode('components/PanelBlocks.vue')
- const observerCallback = (/new IntersectionObserver\(\s*\(entries\)[\s\S]*?\n \},/.exec(badgeVue) || [''])[0]
- check(
- 'H13-on-demand-badge-is-actually-wired-end-to-end',
- /IntersectionObserver/.test(badgeVue) &&
- observerCallback.includes("emit('badge-needed'") &&
- observerCallback.includes('isIntersecting') &&
- /data-ledger-card/.test(badgeVue) &&
- /observer\.observe\(/.test(badgeVue) &&
- /@badge-needed="loadLedgerBadge"/.test(shellVue) &&
- /function loadLedgerBadge/.test(shellVue) &&
- /monitoringStore\.loadLedgerBadge/.test(shellVue),
- '徽标"按需取"真的接通:观察器**回调体里**把露出来的卡片发出去(不是靠退化分支蒙混),外壳接到 store',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T18 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|