| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540 |
- /**
- * 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 () => '<!doctype html><html></html>' }),
- })
- 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 领域适配层全部通过。')
|