|
@@ -0,0 +1,540 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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 领域适配层全部通过。')
|