/** * T05 领域适配层 纯逻辑检查(票 05)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs * * 分四段: * A 分区状态词汇(成功/空/字段缺失/失败/过期/401) * B 领域转换(用**后端演示载荷的真实字段名**喂进来) * C 契约化 mock(五类样例逐一取数 + 只坏一个分区) * D 静态守卫(页面与 store 不碰后端裸字段、不碰凭据) */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { BACKEND_SECTION_STATE, SECTION_STATE, overallState, overallStateText, section, sectionFromResult, sectionStateFromBackend, sectionStateFromFreshness, sectionStateFromReason, sectionView, } from '../../../src/modules/cockpit/domain/sectionState.js' import { REQUEST_REASON } from '../../../src/modules/cockpit/domain/requestReason.js' import { fieldReader, toLocation } from '../../../src/modules/cockpit/domain/fields.js' import { MAP_OBJECT_KIND, batchObjects, summarizeCoordinateValidation, toMapBatch, toMonitoringPoint, toUnassociatedEquipment, } from '../../../src/modules/cockpit/domain/mapSummary.js' import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js' import { EQUIPMENT_SECTIONS, toEquipmentDetailAggregate, toSection, } from '../../../src/modules/cockpit/domain/equipmentDetail.js' import { EVENT_FAMILY, allEvents, eventsOfFamily, sortByEventTimeDesc, toEventRead } from '../../../src/modules/cockpit/domain/events.js' import { WORK_ORDER_CLOSED_CODE, WORK_ORDER_STATES, toStateDistribution, toWorkOrder, toWorkOrderTimeline, } from '../../../src/modules/cockpit/domain/workOrder.js' import { EXPIRED_MISSED_CYCLES, dataAgeText, hasValue, isExpired, toFreshness } from '../../../src/modules/cockpit/domain/freshness.js' import { buildQuery } from '../../../src/modules/cockpit/services/domainRequest.js' import { fetchEquipmentDetailAggregate, fetchEvents, fetchMonitoringPointDetail, fetchWorkOrders } from '../../../src/modules/cockpit/services/detailApi.js' import { fetchFoundation, fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js' import { WS_MESSAGE, fetchFreshnessSection, toDeviceStatusEvent, toSubscribedEvent } from '../../../src/modules/cockpit/services/runtimeApi.js' import { getMockTransportPaths, registerMockTransport, requestCockpitJson, resetMockTransportsForTest, } from '../../../src/modules/cockpit/services/cockpitApi.js' import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js' const checks = [] function check(name, pass, evidence) { checks.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } const builtinConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } } const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } } // ================================================================ A 分区状态词汇 { // 后端四态 1:1 映射。 for (const key of Object.keys(BACKEND_SECTION_STATE)) { const mapped = sectionStateFromBackend(BACKEND_SECTION_STATE[key]) check(`A1-backend-${key}`, mapped === SECTION_STATE[key], `→ ${mapped}`) } check('A1-unknown-backend-state', sectionStateFromBackend('SOMETHING_ELSE') === SECTION_STATE.FAILED, '认不出的后端态不得混进「已就绪」') // 失败原因归类:凭据类 = 数据不可用(ADR 0005,不跳登录页)。 check('A2-401-unavailable', sectionStateFromReason(REQUEST_REASON.UNAUTHORIZED) === SECTION_STATE.UNAVAILABLE) check('A2-no-base-url', sectionStateFromReason(REQUEST_REASON.NO_BASE_URL) === SECTION_STATE.UNAVAILABLE) check('A2-mock-missing', sectionStateFromReason(REQUEST_REASON.MOCK_MISSING) === SECTION_STATE.UNAVAILABLE) check('A2-network-failed', sectionStateFromReason(REQUEST_REASON.UNREACHABLE) === SECTION_STATE.FAILED, '网络失败是「加载失败」而不是「数据不可用」') // 新鲜度:EXPIRED 是「有值但过期」,必须与「空」分开。 check('A3-stale-not-empty', sectionStateFromFreshness('EXPIRED') === SECTION_STATE.STALE) check('A3-empty', sectionStateFromFreshness('EMPTY') === SECTION_STATE.EMPTY) // 401 只标记这一个分区:另一个分区照常成功。 const denied = sectionFromResult({ ok: false, reason: REQUEST_REASON.UNAUTHORIZED, mode: 'real' }) const fine = sectionFromResult({ ok: true, mode: 'mock', data: { x: 1 } }, { toData: (p) => p, isEmpty: () => false }) check('A4-401-marks-own-section', denied.state === SECTION_STATE.UNAVAILABLE && denied.reason === REQUEST_REASON.UNAUTHORIZED) check('A4-neighbour-untouched', fine.state === SECTION_STATE.SUCCESS, '相邻分区不因别的分区 401 而受影响') // 过期分区保留旧值,且带 retained 标识(票 16)。 const stale = sectionFromResult( { ok: true, mode: 'mock', data: { metrics: [] } }, { toData: () => ({ metrics: [{ value: 1 }] }), isEmpty: () => true, freshnessOf: () => 'EXPIRED' }, ) check('A5-stale-keeps-value', stale.state === SECTION_STATE.STALE && stale.retained === true && !!stale.data, '过期优先于空,且保留最后已知值') // 空数据不显示虚假零值:data 为 null。 const emptySection = sectionFromResult({ ok: true, mode: 'mock', data: { metrics: [] } }, { isEmpty: () => true }) check('A6-empty-no-fake-zero', emptySection.state === SECTION_STATE.EMPTY && emptySection.data === null, '空分区不得带 0 值数据') // 整体态:部分可用必须与全坏区分(票 16)。 const partial = overallState({ a: section(SECTION_STATE.SUCCESS), b: section(SECTION_STATE.UNAVAILABLE) }) const allBad = overallState({ a: section(SECTION_STATE.FAILED), b: section(SECTION_STATE.FAILED) }) const allDenied = overallState({ a: section(SECTION_STATE.UNAVAILABLE) }) check('A7-partial', partial === 'partial' && overallStateText(partial).includes('部分')) check('A7-all-failed', allBad === SECTION_STATE.FAILED) check('A7-all-denied', allDenied === SECTION_STATE.UNAVAILABLE) // 视图映射:不可用用票 04 的统一文案,且 detail 可读。 const view = sectionView(section(SECTION_STATE.UNAVAILABLE, { reason: REQUEST_REASON.UNAUTHORIZED })) check('A8-view-tone', view.tone === 'lv1' && view.label === '数据不可用' && view.detail.includes('凭据'), JSON.stringify(view)) check('A8-success-view', sectionView(section(SECTION_STATE.SUCCESS)).tone === 'lv4') check('A8-stale-view', sectionView(section(SECTION_STATE.STALE)).tone === 'lv2' && sectionView(section(SECTION_STATE.STALE)).label.includes('过期')) // 对抗式:内部原因码不得出现在任何面向界面的文案里。 // 这条是真机走查第一轮抓到的回归 —— 失败分区曾把 `unavailable` 直接写进标签文本。 const leaked = [] for (const reason of Object.values(REQUEST_REASON)) { for (const state of [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE]) { const text = sectionView(section(state, { reason })) if (`${text.label}${text.detail}`.includes(reason)) leaked.push(`${state}:${reason}`) } } check('A9-no-reason-code-in-ui', leaked.length === 0, leaked.length ? `泄漏 ${leaked.join(', ')}` : '全部原因都有中文文案,零代号泄漏') check('A9-unknown-reason-safe', sectionView(section(SECTION_STATE.FAILED, { reason: 'some-new-code' })).detail === '', '认不出的原因回空串,不把代号甩给值班人员') check('A9-failed-still-labelled', sectionView(section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNAVAILABLE })).label === '数据加载失败', '没有文案时仍要说出「加载失败」') } // ================================================================ B 领域转换(真实字段名) { // ---- 字段读取:区分「本该有却没有」与「本来就可以为空」 ---- const reader = fieldReader({ a: 'x', zero: 0, empty: '' }, 'r') check('B1-zero-is-value', reader.optionalNum('zero') === 0 && reader.num('zero') === 0, '后端真给的 0 是有效数据,不是缺失') check('B1-empty-string-missing', reader.str('empty') === '' && reader.missing.includes('r.empty'), '空串记一笔缺失') check('B1-optional-null-silent', reader.optionalStr('nope') === '' && !reader.missing.includes('r.nope'), '可选字段缺失不记问题') check('B1-missing-number-null', reader.num('nope') === null && reader.missing.includes('r.nope'), '缺失数字回 null,不回 0') const noValidation = toLocation(undefined) check('B2-unvalidated-not-renderable', noValidation.renderable === false && noValidation.validated === false, '整段坐标校验缺失时不假装可绘制') // ---- 地图摘要(字段名照抄 map-snapshot-demo.html)---- const batch = toMapBatch({ page: 1, pageSize: 2, snapshotAt: '2026-09-02T10:00:00+08:00', batchUpdatedAt: '2026-09-02T10:00:00+08:00', total: 7, complete: false, monitoringPoints: [ { pointCode: 'MP-001', pointName: '供水干管监测点', pointStatus: 'NORMAL', statusReasons: [], longitude: 110.141, latitude: 28.452, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }, { pointCode: 'MP-003', pointName: '污水排口监测点', pointStatus: 'WARNING', statusReasons: ['EQ-FREE-003 预警'], longitude: 110.143, latitude: 28.454, coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' } }, ], unassociatedEquipment: [ { deviceCode: 'EQ-FREE-001', deviceName: '供水压力计-001', longitude: 181, latitude: 28.455, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } }, ], }) check('B3-batch-shape', batch.page === 1 && batch.total === 7 && batch.complete === false && batch.snapshotAt !== '', '分页与快照原样带出(票 08 用)') check('B3-points-mapped', batch.points.length === 2 && batch.points[0].code === 'MP-001' && batch.points[0].kind === MAP_OBJECT_KIND.MONITORING_POINT) check('B3-status-is-backend', batch.points[1].status === 'WARNING', '点位状态用后端解析结果,前端不重算') check('B3-equipment-kind', batch.equipment[0].kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && batch.equipment[0].status === '', '未关联设备没有点位状态,不得借用在线态') const objects = batchObjects(batch) const validation = summarizeCoordinateValidation(objects) check('B4-validation-summary', validation.total === 3 && validation.renderable === 1 && validation.blocked === 2 && validation.states.VALID === 1 && validation.states.INVALID === 1 && validation.states.UNVERIFIED === 1, JSON.stringify(validation)) check('B4-out-of-range-blocked', batch.equipment[0].coordinate.renderable === false && batch.equipment[0].coordinate.reason === 'LONGITUDE_OUT_OF_RANGE', '超范围坐标不上图(票 07 输入)') // 缺失字段:不得伪造。 const degradedPoint = toMonitoringPoint({ pointCode: 'MP-D', pointName: '缺字段点位', pointStatus: 'NORMAL', statusReasons: [] }) check('B5-missing-coord-null', degradedPoint.longitude === null && degradedPoint.latitude === null && degradedPoint.coordinate.renderable === false, '坐标缺失保持 null 且不上图') check('B5-missing-recorded', degradedPoint.missing.includes('monitoringPoint.longitude') && degradedPoint.missing.includes('monitoringPoint.latitude'), JSON.stringify(degradedPoint.missing)) check('B5-unnaffected-fields', degradedPoint.code === 'MP-D' && degradedPoint.status === 'NORMAL', '一个字段缺失不影响其它字段') check('B5-zero-not-faked', toUnassociatedEquipment({ deviceCode: 'E', deviceName: 'n', longitude: 0, latitude: 0 }).longitude === 0, '真 0 坐标照原样带出(是否上图由后端 renderable 决定)') // ---- 监测点详情(字段名照抄 monitoring-point-detail-demo.html)---- const pointDetail = toMonitoringPointDetail({ monitoringPointCode: 'MP-002', monitoringPointName: '燃气调压站监测点', pointStatus: 'ALARM', statusLabel: '报警', statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-001', reason: '压力超高' }], deviceStatistics: { totalDeviceCount: 2, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 }, devices: [ { deviceCode: 'EQ-001', deviceName: '供水压力计-001', deviceTypeName: '压力计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['压力超高'], statusTime: '2026-09-02T09:59:00+08:00' }, { deviceCode: 'EQ-002', deviceName: '智能井盖-002', deviceTypeName: '智能井盖', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' }, ], coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }, { status: 'NORMAL', label: '正常', priority: 4 }], }) check('B6-point-detail', pointDetail.code === 'MP-002' && pointDetail.statistics.total === 2 && pointDetail.statusReasons[0].reason === '压力超高') check('B6-device-order-kept', pointDetail.devices.map((d) => d.code).join(',') === 'EQ-001,EQ-002', '设备卡片顺序由后端决定(票 10 验收)') check('B6-dictionary-priority', pointDetail.statusDictionary[0].priority === 1 && pointDetail.statusDictionary.length === 2, '状态字典带后端优先级(票 09 用)') // ---- 工单七态 ---- check('B7-seven-states', WORK_ORDER_STATES.length === 7 && WORK_ORDER_STATES.map((s) => s.code).join('') === '1234567') check('B7-closed-terminal', WORK_ORDER_STATES.filter((s) => s.terminal).map((s) => s.code).join(',') === '6,7', '只有已结案/已取消是终态') const order = toWorkOrder({ workOrderCode: 'WO-1', stateCode: 4, stateName: '处理中', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: 'ALM-1' } }) check('B7-work-order', order.code === 'WO-1' && order.stateCode === 4 && order.realFinishTime === '' && order.event.source === 'ALARM', '未结案 realFinishTime 为 null(本来就没有,不算缺失)') // 状态名缺失时用字典补,不显示裸数字。 const degradedOrder = toWorkOrder({ workOrderCode: 'WO-2', stateCode: 3 }) check('B7-state-name-from-dictionary', degradedOrder.stateName === '已接单', `→ ${degradedOrder.stateName}`) // 七态分布:缺失补 0 是真实计数;字典外的键不丢。 const distribution = toStateDistribution({ 4: 2, 6: 1, 99: 5 }) check('B8-distribution-fills-zero', distribution.distribution[1] === 0 && distribution.distribution[4] === 2) check('B8-distribution-total', distribution.total === 3, '总数只算七态内的(3),不把未知键混进来') check('B8-unknown-kept', distribution.unknown.length === 1 && distribution.unknown[0].key === '99', '字典外的键单独列出,不静默丢') check('B8-closed-code', WORK_ORDER_CLOSED_CODE === 6) // 时间线:按 operatedAt 升序 + 回退动作如实带出(票 12:不是独立终态)。 const timeline = toWorkOrderTimeline({ workOrder: { workOrderCode: 'WO-3', stateCode: 4 }, timelineEntries: [ { logId: 3, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '李四', operatedAt: '2026-09-02T11:00:00+08:00', stateCode: 4, stateName: '处理中' }, { logId: 1, operationName: '创建工单', operationDesc: '待派单', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 1, stateName: '待派单' }, { logId: 2, operationName: '提交验收', operationDesc: '待验收', operatorName: '张三', operatedAt: '2026-09-02T10:00:00+08:00', stateCode: 5, stateName: '待验收' }, ], }) check('B9-timeline-sorted', timeline.entries.map((e) => e.operationName).join(',') === '创建工单,提交验收,验收驳回', '轨迹按时间升序(后端顺序不保证)') check('B9-rollback-is-action', timeline.entries[2].stateCode === 4 && timeline.entries[2].operationName === '验收驳回', '验收驳回是回到「处理中」的动作,不是第八态') // ---- 事件(字段名照抄 event-read-demo.html)---- const eventRead = toEventRead({ deviceCode: 'TC-1', deviceTypeName: '污水环境监测设备', state: 'READY', stateReason: 'READY', warnings: [{ warningNo: 'WRN-1', warningName: '污水COD预警', level: 2, levelColor: 'YELLOW', status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', value: 18.2, unit: 'mg/L', deviceCode: 'TC-1' }], alarms: [{ alarmId: 'ALM-1', warningCode: 'WS_ENV_HIGH', warningName: '污水COD报警', level: 1, levelColor: 'RED', status: 'UNHANDLED', eventTime: '2026-09-02T10:00:00+08:00', value: 42, thresholdMinValue: 40, thresholdMaxValue: 50, unit: 'mg/L', deviceCode: 'TC-1' }], alarmWarningRelations: [{ relationId: 'REL-1', warningNo: 'WRN-1', alarmId: 'ALM-1', relatedAt: '2026-09-02T10:00:00+08:00' }], }) check('B10-families', eventRead.warnings[0].family === EVENT_FAMILY.WARNING && eventRead.alarms[0].family === EVENT_FAMILY.ALARM) check('B10-unified-code', eventRead.warnings[0].code === 'WRN-1' && eventRead.alarms[0].code === 'ALM-1', '两类事件统一成 code,列表不必分支') check('B10-alarm-keeps-rule-code', eventRead.alarms[0].ruleCode === 'WS_ENV_HIGH', '报警的 warningCode 是规则编码,另存避免与事件编码混淆') check('B10-threshold-on-alarm', eventRead.alarms[0].thresholdMaxValue === 50 && eventRead.warnings[0].thresholdMaxValue === null, '阈值只有报警有,预警不得伪造') check('B10-relation', eventRead.relations[0].id === 'REL-1') check('B10-family-switch', eventsOfFamily(eventRead, EVENT_FAMILY.ALARM).length === 1 && eventsOfFamily(eventRead, EVENT_FAMILY.WARNING).length === 1 && allEvents(eventRead).length === 2) check('B10-sort-desc', sortByEventTimeDesc(allEvents(eventRead))[0].code === 'ALM-1', '最新在前(票 13 默认最新 10-15 条)') // 等级配色:认不出就不猜。 const unknownColor = toEventRead({ warnings: [{ warningNo: 'W', level: 9, levelColor: 'PURPLE' }], alarms: [] }).warnings[0] check('B11-unknown-level-color', unknownColor.levelColor === '' && unknownColor.level === 9, '认不出的配色回空串,不自造颜色') // ---- 新鲜度 ---- const fresh = toFreshness({ updatedAt: '2026-09-02T09:59:50+08:00', freshness: 'CURRENT', calibrationPeriodSeconds: 60, missedCycles: 0, dataAgeSeconds: 5, retained: false }) const expired = toFreshness({ freshness: 'EXPIRED', staleReason: 'TWO_UPDATE_CYCLES_MISSED', missedCycles: 2, dataAgeSeconds: 120, retained: true }) const blank = toFreshness({ freshness: 'CURRENT' }) check('B12-expired-by-backend', isExpired(expired) === true && isExpired(fresh) === false) check('B12-expired-fallback', isExpired({ missedCycles: EXPIRED_MISSED_CYCLES }) === true, '后端没给 freshness 时才用 missedCycles 兜底') check('B12-retained-has-value', hasValue(expired) === true && hasValue(toFreshness({ freshness: 'EMPTY' })) === false) check('B12-age-placeholder', dataAgeText(blank) === '—' && dataAgeText(fresh) === '5 秒前' && dataAgeText(expired) === '2 分钟前', '缺时间显示占位符,不显示「0 秒」') // ---- 设备详情聚合:分区独立 ---- const aggregate = toEquipmentDetailAggregate({ deviceCode: 'TC-1', aggregateState: 'PARTIAL', archive: { state: 'FAILED', reason: 'READ_FAILURE', message: '设备档案数据暂不可用', data: null }, metrics: { state: 'SUCCESS', reason: 'SUCCESS', data: { deviceCode: 'TC-1', state: 'CURRENT', metrics: [{ metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L' }] } }, warnings: { state: 'SUCCESS', reason: 'SUCCESS', data: [] }, alarms: { state: 'EMPTY', reason: 'NO_EVENTS', data: null }, workOrders: { state: 'STALE', reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] }, }) check('B13-sections-all-present', EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]), '八个分区都在(后端没给的分区也留位)') check('B13-failed-isolated', aggregate.sections.archive.state === SECTION_STATE.FAILED && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '档案失败不影响指标成功(票 11 验收第 3 条)') check('B13-failed-keeps-message', aggregate.sections.archive.reason === '设备档案数据暂不可用') check('B13-empty-distinct', aggregate.sections.alarms.state === SECTION_STATE.EMPTY && aggregate.sections.alarms.data === null) check('B13-stale-retains', aggregate.sections.workOrders.state === SECTION_STATE.STALE && aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1', '过期分区保留旧值') check('B13-missing-section', aggregate.sections.timelines.state === SECTION_STATE.FAILED, '后端整段缺失时该分区算失败,不算成功') check('B13-metric-zero', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'FLOW', value: 0, unit: 'm³/h' }] } }).data.metrics[0].value === 0, '指标真 0 不得被当成缺失') check('B13-metric-missing-null', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'COD', metricName: 'COD' }] } }).data.metrics[0].value === null, '缺 value 显示占位符,不显示虚假零值') check('B13-aggregate-state-not-authoritative', aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '界面分区状态以各分区自己的为准') // 未知分区原样带出,不静默丢。 check('B13-unknown-section-kept', toSection('brandNew', { state: 'SUCCESS', data: { any: 1 } }).data.any === 1) } // ================================================================ C 契约化 mock { const handlers = { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } const register = (options) => registerCockpitMocks({ ...options, handlers }) // ---- C1 成功样例:三个领域模块都能取到领域对象 ---- register({ scenario: 'success' }) // 票 08 起 mock 也按后端真实语义分页(分页窗口横跨监测点与未关联设备、第 2 页起要带快照), // 所以这里取的是**第 1 页**:它只是一条总序列里的一段,不再是"一次拿到全部"。 const snapshot = await fetchMapSnapshot({ page: 1, pageSize: 2, config: builtinConfig }) check( 'C1-map-success', snapshot.state === SECTION_STATE.SUCCESS && snapshot.data.points.length + snapshot.data.equipment.length === 2, JSON.stringify({ state: snapshot.state, points: snapshot.data.points.length, equipment: snapshot.data.equipment.length }), ) check( 'C1-map-snapshot-locked', snapshot.data.snapshotAt !== '' && snapshot.data.complete === false, JSON.stringify({ snapshotAt: snapshot.data.snapshotAt, complete: snapshot.data.complete, total: snapshot.data.total }), ) // 后续页带着第 1 页的快照请求:它是同一次加载的一部分(票 08 验收第 3 条)。 const snapshotPage3 = await fetchMapSnapshot({ page: 3, pageSize: 2, snapshotAt: snapshot.data.snapshotAt, config: builtinConfig }) check( 'C1-map-page-locked-to-snapshot', snapshotPage3.state === SECTION_STATE.SUCCESS && snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt && snapshotPage3.data.complete === true, JSON.stringify({ sameSnapshot: snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt, complete: snapshotPage3.data.complete }), ) const aggregate = await fetchEquipmentDetailAggregate('TC-PS-WS-202609020012', { config: builtinConfig }) check('C1-aggregate-success', aggregate.state === SECTION_STATE.SUCCESS && aggregate.data.sections.archive.data.name === '污水环境监测设备') check('C1-aggregate-substates', aggregate.data.sections.metrics.state === SECTION_STATE.SUCCESS && aggregate.data.sections.alarms.data[0].code === 'ALM-20260902-001') const point = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig }) check('C1-point-success', point.state === SECTION_STATE.SUCCESS && point.data.code === 'MP-002' && point.data.devices.length === 2) check('C1-point-path-param', point.state === SECTION_STATE.SUCCESS, '模板路径 /monitoring-points/* 命中(带路径参数的接口也要能 mock)') const events = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig }) check('C1-events-success', events.state === SECTION_STATE.SUCCESS && allEvents(events.data).length === 2) const orders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig }) check('C1-work-orders-success', orders.state === SECTION_STATE.SUCCESS && orders.data.orders.length === 1) const freshness = await fetchFreshnessSection('TC-PS-WS-202609020012', { config: builtinConfig }) check('C1-freshness-success', freshness.state === SECTION_STATE.SUCCESS && freshness.data.freshness === 'CURRENT') // ---- C2 空数据样例 ---- register({ scenario: 'empty' }) const emptyAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) check('C2-empty-aggregate', emptyAggregate.state === SECTION_STATE.SUCCESS && emptyAggregate.data.sections.archive.state === SECTION_STATE.EMPTY, '整体取到了,但每个分区各自是「无数据」') const emptyOrders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig }) check('C2-empty-orders', emptyOrders.state === SECTION_STATE.EMPTY && emptyOrders.data === null, '空列表分区不得带空数组冒充数据') const emptyFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig }) check('C2-empty-freshness', emptyFreshness.state === SECTION_STATE.EMPTY) // ---- C3 字段缺失样例 ---- register({ scenario: 'degraded' }) const degradedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) const degradedArchive = degradedAggregate.data.sections.archive check('C3-degraded-archive', degradedArchive.state === SECTION_STATE.SUCCESS && degradedArchive.data.code === 'TC-1' && degradedArchive.data.name === '', '缺字段的分区仍算成功,只是字段为空') check('C3-degraded-missing-recorded', degradedArchive.data.missing.includes('archive.deviceName'), JSON.stringify(degradedArchive.data.missing)) check('C3-degraded-order-name', degradedAggregate.data.sections.workOrders.data[0].stateName === '已接单', '工单状态名缺失时用七态字典补') const degradedFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig }) check('C3-degraded-age', dataAgeText(degradedFreshness.data) === '—', '缺 dataAgeSeconds 时显示占位符') const degradedPoint = await fetchMonitoringPointDetail('MP-DEGRADED', { config: builtinConfig }) check('C3-degraded-point', degradedPoint.state === SECTION_STATE.SUCCESS && degradedPoint.data.code === 'MP-DEGRADED', '点位记录缺字段仍算成功(缺字段 ≠ 空记录)') // ---- C4 失败样例 ---- register({ scenario: 'failed' }) const failedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) check('C4-failed-aggregate', failedAggregate.state === SECTION_STATE.FAILED && failedAggregate.data === null, '处理器抛出 → 分区失败,不抛给页面') const failedSnapshot = await fetchMapSnapshot({ config: builtinConfig }) check('C4-failed-snapshot', failedSnapshot.state === SECTION_STATE.FAILED && failedSnapshot.reason === REQUEST_REASON.UNAVAILABLE) // ---- C5 过期样例 ---- register({ scenario: 'stale' }) const staleAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) check('C5-stale-metrics', staleAggregate.data.sections.metrics.state === SECTION_STATE.STALE && staleAggregate.data.sections.metrics.retained === true, '过期分区带 retained,界面须显示过期标识') check('C5-stale-keeps-other', staleAggregate.data.sections.archive.state === SECTION_STATE.SUCCESS, '过期不牵连其它分区') const staleFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig }) check('C5-stale-freshness', staleFreshness.state === SECTION_STATE.STALE && staleFreshness.data.freshness === 'EXPIRED' && isExpired(staleFreshness.data)) // ---- C6 401:只坏一个分区,其余照常成功(票 05 验收第 6 条)---- register({ scenario: 'unauthorized', onlyPath: '/equipments/*/detail-aggregate' }) const deniedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) const okSnapshot = await fetchMapSnapshot({ config: builtinConfig }) const okFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig }) check('C6-401-isolated', deniedAggregate.state === SECTION_STATE.UNAVAILABLE && deniedAggregate.reason === REQUEST_REASON.UNAUTHORIZED, JSON.stringify({ state: deniedAggregate.state, reason: deniedAggregate.reason })) check('C6-neighbours-survive', okSnapshot.state === SECTION_STATE.SUCCESS && okFreshness.state === SECTION_STATE.SUCCESS, '受影响分区之外照常成功(保留已成功分区)') check('C6-overall-partial', overallState({ a: deniedAggregate, b: okSnapshot, c: okFreshness }) === 'partial') check('C6-view-is-data-unavailable', sectionView(deniedAggregate).label === '数据不可用') // 前缀只坏一族接口。 register({ scenario: 'unauthorized', prefix: '/equipments/' }) const prefixMap = await fetchMapSnapshot({ config: builtinConfig }) const prefixAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig }) const prefixPoint = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig }) check('C6-prefix-isolated', prefixAggregate.state === SECTION_STATE.UNAVAILABLE && prefixMap.state === SECTION_STATE.SUCCESS && prefixPoint.state === SECTION_STATE.SUCCESS, '前缀选中一族接口,其余分区与地图不受影响') // ---- C7 未注册接口:明确原因,不静默 ---- resetMockTransportsForTest() const missing = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig }) check('C7-unregistered', missing.state === SECTION_STATE.UNAVAILABLE && missing.reason === REQUEST_REASON.MOCK_MISSING) // ---- C8 真实模式:不带凭据的 mock 分支不泄漏,real 分支走同一适配层 ---- register({ scenario: 'success' }) let seenHeaders = null const realResult = await fetchMapSnapshot({ config: realConfig, fetchImpl: async (url, init) => { seenHeaders = { url, headers: init.headers } return { ok: true, status: 200, text: async () => JSON.stringify({ page: 1, pageSize: 1, total: 0, complete: true, monitoringPoints: [], unassociatedEquipment: [] }) } }, }) check('C8-real-same-adapter', realResult.state === SECTION_STATE.EMPTY && realResult.mode === 'real', '真接口与 mock 走同一适配层与同一转换') check('C8-real-attaches-credential', seenHeaders.headers.Authorization === 'Bearer readonly', '凭据只在适配层附加(票 04 的接缝未被绕过)') check('C8-real-url', seenHeaders.url.startsWith('https://gw/api/cockpit/v1/map-snapshot'), seenHeaders.url) // 假 401 走真接口路径:分区变「数据不可用」,不抛异常。 const realDenied = await fetchMapSnapshot({ config: realConfig, fetchImpl: async () => ({ ok: false, status: 401, text: async () => '{"code":401}' }), }) check('C8-real-401', realDenied.state === SECTION_STATE.UNAVAILABLE && realDenied.reason === REQUEST_REASON.UNAUTHORIZED) // 真接口 200 HTML(dev 回退)→ 坏载荷,不当成成功。 const realHtml = await fetchMapSnapshot({ config: realConfig, fetchImpl: async () => ({ ok: true, status: 200, text: async () => '' }), }) check('C8-real-bad-payload', realHtml.state === SECTION_STATE.FAILED && realHtml.reason === REQUEST_REASON.BAD_PAYLOAD) // ---- C9 查询串拼装 ---- check('C9-query-empty', buildQuery({ a: '', b: null, c: undefined }) === '') check('C9-query-encode', buildQuery({ page: 1, snapshotAt: '2026-09-02T10:00:00+08:00' }) === '?page=1&snapshotAt=2026-09-02T10%3A00%3A00%2B08%3A00', buildQuery({ page: 1, snapshotAt: '2026-09-02T10:00:00+08:00' })) check('C9-query-array', buildQuery({ x: ['a', 'b'] }) === '?x=a&x=b') // 历史事件走 history 路径并带时间范围。 register({ scenario: 'success' }) let historyUrl = '' await fetchEvents( { deviceCode: 'TC-1', startTime: '2026-09-01T00:00:00+08:00', endTime: '2026-09-02T00:00:00+08:00' }, { config: realConfig, fetchImpl: async (url) => { historyUrl = url; return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'READY', warnings: [], alarms: [] }) } } }, ) check('C9-history-path', historyUrl.includes('/events/history?') && historyUrl.includes('startTime='), historyUrl) let currentUrl = '' await fetchEvents({ deviceCode: 'TC-1' }, { config: realConfig, fetchImpl: async (url) => { currentUrl = url; return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'READY', warnings: [], alarms: [] }) } } }) check('C9-current-path', currentUrl.includes('/events/current') && !currentUrl.includes('startTime='), currentUrl) // 缺编码:不发请求,直接给出可读原因。 let called = 0 const noCode = await fetchEquipmentDetailAggregate('', { config: realConfig, fetchImpl: async () => { called += 1; return { ok: true, status: 200, text: async () => '{}' } } }) check('C9-no-code-no-request', called === 0 && noCode.state === SECTION_STATE.FAILED && noCode.reason.includes('编码'), noCode.reason) // ---- C10 foundation:地图帧不可绘制 → 空而不是成功 ---- registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'EMPTY', renderable: false, reason: '驾驶舱基础数据为空' }, freshness: { status: 'FRESH' } })) const emptyFrame = await fetchFoundation({ config: builtinConfig }) check('C10-foundation-empty-frame', emptyFrame.state === SECTION_STATE.EMPTY && emptyFrame.reason === '驾驶舱基础数据为空') registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'READY', renderable: true }, freshness: { status: 'FRESH' } })) const readyFrame = await fetchFoundation({ config: builtinConfig }) check('C10-foundation-ready', readyFrame.state === SECTION_STATE.SUCCESS && readyFrame.data.freshness.status === 'FRESH') // ---- C11 WS 增量帧 → 领域事件(字段名两个都认)---- const delta = toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, deviceCode: 'DEV001', device_code: 'DEV001', data: { status: 'ALARM', statusTime: '2026-09-02T10:00:00+08:00', serverUpdatedAt: '2026-09-02T10:00:01+08:00', reason: '浓度超限' } }) check('C11-delta-online-frame', delta && delta.deviceCode === 'DEV001' && delta.status === 'ALARM' && delta.reason === '浓度超限', JSON.stringify(delta)) const dtoDelta = toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, deviceCode: 'DEV002', data: { monitoringState: 'OFFLINE' } }) check('C11-delta-dto-name', dtoDelta && dtoDelta.status === 'OFFLINE', '后端 DTO 用 monitoringState,线上帧用 status,两个都要认') check('C11-unknown-frame', toDeviceStatusEvent({ type: 'somethingElse' }) === null && toDeviceStatusEvent(null) === null, '认不出的帧回 null,不猜') check('C11-no-code', toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, data: { status: 'ALARM' } }) === null, '没有设备编码的增量帧无法回联点位,必须丢弃') check('C11-subscribed', toSubscribedEvent({ type: WS_MESSAGE.SUBSCRIBED, scope: 'all', serverUpdatedAt: 't' }).scope === 'all') // ---- C12 注册幂等:重复注册只覆盖不叠加 ---- register({ scenario: 'success' }) const firstPaths = getMockTransportPaths().length register({ scenario: 'success' }) check('C12-idempotent', getMockTransportPaths().length === firstPaths, `${getMockTransportPaths().length} 个路径(两次注册后不翻倍)`) check('C12-covers-shared-domain', ['/map-snapshot', '/monitoring-points/*', '/equipments/*/detail-aggregate'].every((p) => getMockTransportPaths().includes(p)), getMockTransportPaths().join(', ')) } // ================================================================ D 静态守卫(对抗式) { const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') // 页面与 store 不得出现后端裸字段名(票 05 验收第 4 条)。 // 按键名清单查:这些是后端 JSON 里的字段,领域对象已全部改名。 const BACKEND_FIELD_PATTERN = /\b(monitoringPoints|unassociatedEquipment|coordinateValidation|pointStatusCode|deviceStatistics|aggregateState|timelineEntries|alarmWarningRelations|warningNo|alarmId|stateDictionary|stateReason|missingCycles|dataAgeSeconds|calibrationPeriodSeconds|staleReason|workOrders|deviceStatus)\b/ const pageAndStoreFiles = [ 'CockpitShell.vue', 'components/MapCanvas.vue', 'components/CockpitPanel.vue', 'components/PanelPlaceholder.vue', 'components/StatusTag.vue', 'components/SectionStateTag.vue', 'components/CockpitIcon.vue', // 票 10/11 的详情浮层与设备详情分区(新增文件一律补进守卫面,否则新代码是检查不到的荒地)。 'components/PointDetailCard.vue', 'components/EquipmentDetailSections.vue', 'stores/uiStore.js', 'stores/filterStore.js', 'stores/detailStore.js', ] for (const file of pageAndStoreFiles) { const text = readModule(file) const found = BACKEND_FIELD_PATTERN.exec(text) check(`D1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段') } // 页面与 store 不碰凭据(票 04 立的规矩,票 05 不得破坏)。 const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/ for (const file of pageAndStoreFiles) { check(`D2-${file}-no-credential`, !CREDENTIAL_PATTERN.test(readModule(file)), '页面与 store 不碰凭据') } // 领域模块不直接发请求:转换必须与传输分离(否则转换单测就得起网络)。 const domainFiles = [ 'domain/sectionState.js', 'domain/mapSummary.js', 'domain/pointDetail.js', 'domain/equipmentDetail.js', // 票 11:设备详情分区的界面视图(纯函数,不 import service、自己不发请求)。 'domain/equipmentDetailView.js', 'domain/workOrder.js', 'domain/events.js', 'domain/freshness.js', 'domain/fields.js', ] for (const file of domainFiles) { const text = readModule(file) check(`D3-${file}-pure`, !/\bfetch\s*\(|XMLHttpRequest/.test(text), '领域转换不自己发请求') } // domain 不得反向依赖 service(层次倒挂)。 for (const file of domainFiles) { const text = readModule(file) check(`D4-${file}-no-service-import`, !/from '\.\.\/services\//.test(text), 'domain 不 import service') } // 登录跳转:票 05 的场景(含 401)不得引入任何登录入口。 for (const file of [...pageAndStoreFiles, 'mocks/contractMocks.js', 'mocks/index.js', ...domainFiles]) { const text = readModule(file) check(`D5-${file}-no-login-redirect`, !/location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text), '不跳登录页') } } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T05 适配层检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) { console.log('未通过:') for (const item of failed) console.log(` · ${item.name}`) process.exit(1) } console.log('T05 领域适配层全部通过。')