t05-adapter.mjs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. /**
  2. * T05 领域适配层 纯逻辑检查(票 05)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs
  6. *
  7. * 分四段:
  8. * A 分区状态词汇(成功/空/字段缺失/失败/过期/401)
  9. * B 领域转换(用**后端演示载荷的真实字段名**喂进来)
  10. * C 契约化 mock(五类样例逐一取数 + 只坏一个分区)
  11. * D 静态守卫(页面与 store 不碰后端裸字段、不碰凭据)
  12. */
  13. import assert from 'node:assert/strict'
  14. import { readFileSync } from 'node:fs'
  15. import {
  16. BACKEND_SECTION_STATE,
  17. SECTION_STATE,
  18. overallState,
  19. overallStateText,
  20. section,
  21. sectionFromResult,
  22. sectionStateFromBackend,
  23. sectionStateFromFreshness,
  24. sectionStateFromReason,
  25. sectionView,
  26. } from '../../../src/modules/cockpit/domain/sectionState.js'
  27. import { REQUEST_REASON } from '../../../src/modules/cockpit/domain/requestReason.js'
  28. import { fieldReader, toLocation } from '../../../src/modules/cockpit/domain/fields.js'
  29. import {
  30. MAP_OBJECT_KIND,
  31. batchObjects,
  32. summarizeCoordinateValidation,
  33. toMapBatch,
  34. toMonitoringPoint,
  35. toUnassociatedEquipment,
  36. } from '../../../src/modules/cockpit/domain/mapSummary.js'
  37. import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
  38. import {
  39. EQUIPMENT_SECTIONS,
  40. toEquipmentDetailAggregate,
  41. toSection,
  42. } from '../../../src/modules/cockpit/domain/equipmentDetail.js'
  43. import { EVENT_FAMILY, allEvents, eventsOfFamily, sortByEventTimeDesc, toEventRead } from '../../../src/modules/cockpit/domain/events.js'
  44. import {
  45. WORK_ORDER_CLOSED_CODE,
  46. WORK_ORDER_STATES,
  47. toStateDistribution,
  48. toWorkOrder,
  49. toWorkOrderTimeline,
  50. } from '../../../src/modules/cockpit/domain/workOrder.js'
  51. import { EXPIRED_MISSED_CYCLES, dataAgeText, hasValue, isExpired, toFreshness } from '../../../src/modules/cockpit/domain/freshness.js'
  52. import { buildQuery } from '../../../src/modules/cockpit/services/domainRequest.js'
  53. import { fetchEquipmentDetailAggregate, fetchEvents, fetchMonitoringPointDetail, fetchWorkOrders } from '../../../src/modules/cockpit/services/detailApi.js'
  54. import { fetchFoundation, fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
  55. import { WS_MESSAGE, fetchFreshnessSection, toDeviceStatusEvent, toSubscribedEvent } from '../../../src/modules/cockpit/services/runtimeApi.js'
  56. import {
  57. getMockTransportPaths,
  58. registerMockTransport,
  59. requestCockpitJson,
  60. resetMockTransportsForTest,
  61. } from '../../../src/modules/cockpit/services/cockpitApi.js'
  62. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  63. const checks = []
  64. function check(name, pass, evidence) {
  65. checks.push({ name, pass })
  66. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  67. }
  68. const builtinConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  69. const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } }
  70. // ================================================================ A 分区状态词汇
  71. {
  72. // 后端四态 1:1 映射。
  73. for (const key of Object.keys(BACKEND_SECTION_STATE)) {
  74. const mapped = sectionStateFromBackend(BACKEND_SECTION_STATE[key])
  75. check(`A1-backend-${key}`, mapped === SECTION_STATE[key], `→ ${mapped}`)
  76. }
  77. check('A1-unknown-backend-state', sectionStateFromBackend('SOMETHING_ELSE') === SECTION_STATE.FAILED, '认不出的后端态不得混进「已就绪」')
  78. // 失败原因归类:凭据类 = 数据不可用(ADR 0005,不跳登录页)。
  79. check('A2-401-unavailable', sectionStateFromReason(REQUEST_REASON.UNAUTHORIZED) === SECTION_STATE.UNAVAILABLE)
  80. check('A2-no-base-url', sectionStateFromReason(REQUEST_REASON.NO_BASE_URL) === SECTION_STATE.UNAVAILABLE)
  81. check('A2-mock-missing', sectionStateFromReason(REQUEST_REASON.MOCK_MISSING) === SECTION_STATE.UNAVAILABLE)
  82. check('A2-network-failed', sectionStateFromReason(REQUEST_REASON.UNREACHABLE) === SECTION_STATE.FAILED, '网络失败是「加载失败」而不是「数据不可用」')
  83. // 新鲜度:EXPIRED 是「有值但过期」,必须与「空」分开。
  84. check('A3-stale-not-empty', sectionStateFromFreshness('EXPIRED') === SECTION_STATE.STALE)
  85. check('A3-empty', sectionStateFromFreshness('EMPTY') === SECTION_STATE.EMPTY)
  86. // 401 只标记这一个分区:另一个分区照常成功。
  87. const denied = sectionFromResult({ ok: false, reason: REQUEST_REASON.UNAUTHORIZED, mode: 'real' })
  88. const fine = sectionFromResult({ ok: true, mode: 'mock', data: { x: 1 } }, { toData: (p) => p, isEmpty: () => false })
  89. check('A4-401-marks-own-section', denied.state === SECTION_STATE.UNAVAILABLE && denied.reason === REQUEST_REASON.UNAUTHORIZED)
  90. check('A4-neighbour-untouched', fine.state === SECTION_STATE.SUCCESS, '相邻分区不因别的分区 401 而受影响')
  91. // 过期分区保留旧值,且带 retained 标识(票 16)。
  92. const stale = sectionFromResult(
  93. { ok: true, mode: 'mock', data: { metrics: [] } },
  94. { toData: () => ({ metrics: [{ value: 1 }] }), isEmpty: () => true, freshnessOf: () => 'EXPIRED' },
  95. )
  96. check('A5-stale-keeps-value', stale.state === SECTION_STATE.STALE && stale.retained === true && !!stale.data, '过期优先于空,且保留最后已知值')
  97. // 空数据不显示虚假零值:data 为 null。
  98. const emptySection = sectionFromResult({ ok: true, mode: 'mock', data: { metrics: [] } }, { isEmpty: () => true })
  99. check('A6-empty-no-fake-zero', emptySection.state === SECTION_STATE.EMPTY && emptySection.data === null, '空分区不得带 0 值数据')
  100. // 整体态:部分可用必须与全坏区分(票 16)。
  101. const partial = overallState({ a: section(SECTION_STATE.SUCCESS), b: section(SECTION_STATE.UNAVAILABLE) })
  102. const allBad = overallState({ a: section(SECTION_STATE.FAILED), b: section(SECTION_STATE.FAILED) })
  103. const allDenied = overallState({ a: section(SECTION_STATE.UNAVAILABLE) })
  104. check('A7-partial', partial === 'partial' && overallStateText(partial).includes('部分'))
  105. check('A7-all-failed', allBad === SECTION_STATE.FAILED)
  106. check('A7-all-denied', allDenied === SECTION_STATE.UNAVAILABLE)
  107. // 视图映射:不可用用票 04 的统一文案,且 detail 可读。
  108. const view = sectionView(section(SECTION_STATE.UNAVAILABLE, { reason: REQUEST_REASON.UNAUTHORIZED }))
  109. check('A8-view-tone', view.tone === 'lv1' && view.label === '数据不可用' && view.detail.includes('凭据'), JSON.stringify(view))
  110. check('A8-success-view', sectionView(section(SECTION_STATE.SUCCESS)).tone === 'lv4')
  111. check('A8-stale-view', sectionView(section(SECTION_STATE.STALE)).tone === 'lv2' && sectionView(section(SECTION_STATE.STALE)).label.includes('过期'))
  112. // 对抗式:内部原因码不得出现在任何面向界面的文案里。
  113. // 这条是真机走查第一轮抓到的回归 —— 失败分区曾把 `unavailable` 直接写进标签文本。
  114. const leaked = []
  115. for (const reason of Object.values(REQUEST_REASON)) {
  116. for (const state of [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE]) {
  117. const text = sectionView(section(state, { reason }))
  118. if (`${text.label}${text.detail}`.includes(reason)) leaked.push(`${state}:${reason}`)
  119. }
  120. }
  121. check('A9-no-reason-code-in-ui', leaked.length === 0, leaked.length ? `泄漏 ${leaked.join(', ')}` : '全部原因都有中文文案,零代号泄漏')
  122. check('A9-unknown-reason-safe', sectionView(section(SECTION_STATE.FAILED, { reason: 'some-new-code' })).detail === '', '认不出的原因回空串,不把代号甩给值班人员')
  123. check('A9-failed-still-labelled', sectionView(section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNAVAILABLE })).label === '数据加载失败', '没有文案时仍要说出「加载失败」')
  124. }
  125. // ================================================================ B 领域转换(真实字段名)
  126. {
  127. // ---- 字段读取:区分「本该有却没有」与「本来就可以为空」 ----
  128. const reader = fieldReader({ a: 'x', zero: 0, empty: '' }, 'r')
  129. check('B1-zero-is-value', reader.optionalNum('zero') === 0 && reader.num('zero') === 0, '后端真给的 0 是有效数据,不是缺失')
  130. check('B1-empty-string-missing', reader.str('empty') === '' && reader.missing.includes('r.empty'), '空串记一笔缺失')
  131. check('B1-optional-null-silent', reader.optionalStr('nope') === '' && !reader.missing.includes('r.nope'), '可选字段缺失不记问题')
  132. check('B1-missing-number-null', reader.num('nope') === null && reader.missing.includes('r.nope'), '缺失数字回 null,不回 0')
  133. const noValidation = toLocation(undefined)
  134. check('B2-unvalidated-not-renderable', noValidation.renderable === false && noValidation.validated === false, '整段坐标校验缺失时不假装可绘制')
  135. // ---- 地图摘要(字段名照抄 map-snapshot-demo.html)----
  136. const batch = toMapBatch({
  137. page: 1,
  138. pageSize: 2,
  139. snapshotAt: '2026-09-02T10:00:00+08:00',
  140. batchUpdatedAt: '2026-09-02T10:00:00+08:00',
  141. total: 7,
  142. complete: false,
  143. monitoringPoints: [
  144. { pointCode: 'MP-001', pointName: '供水干管监测点', pointStatus: 'NORMAL', statusReasons: [], longitude: 110.141, latitude: 28.452, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } },
  145. { 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' } },
  146. ],
  147. unassociatedEquipment: [
  148. { deviceCode: 'EQ-FREE-001', deviceName: '供水压力计-001', longitude: 181, latitude: 28.455, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } },
  149. ],
  150. })
  151. check('B3-batch-shape', batch.page === 1 && batch.total === 7 && batch.complete === false && batch.snapshotAt !== '', '分页与快照原样带出(票 08 用)')
  152. check('B3-points-mapped', batch.points.length === 2 && batch.points[0].code === 'MP-001' && batch.points[0].kind === MAP_OBJECT_KIND.MONITORING_POINT)
  153. check('B3-status-is-backend', batch.points[1].status === 'WARNING', '点位状态用后端解析结果,前端不重算')
  154. check('B3-equipment-kind', batch.equipment[0].kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && batch.equipment[0].status === '', '未关联设备没有点位状态,不得借用在线态')
  155. const objects = batchObjects(batch)
  156. const validation = summarizeCoordinateValidation(objects)
  157. 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))
  158. check('B4-out-of-range-blocked', batch.equipment[0].coordinate.renderable === false && batch.equipment[0].coordinate.reason === 'LONGITUDE_OUT_OF_RANGE', '超范围坐标不上图(票 07 输入)')
  159. // 缺失字段:不得伪造。
  160. const degradedPoint = toMonitoringPoint({ pointCode: 'MP-D', pointName: '缺字段点位', pointStatus: 'NORMAL', statusReasons: [] })
  161. check('B5-missing-coord-null', degradedPoint.longitude === null && degradedPoint.latitude === null && degradedPoint.coordinate.renderable === false, '坐标缺失保持 null 且不上图')
  162. check('B5-missing-recorded', degradedPoint.missing.includes('monitoringPoint.longitude') && degradedPoint.missing.includes('monitoringPoint.latitude'), JSON.stringify(degradedPoint.missing))
  163. check('B5-unnaffected-fields', degradedPoint.code === 'MP-D' && degradedPoint.status === 'NORMAL', '一个字段缺失不影响其它字段')
  164. check('B5-zero-not-faked', toUnassociatedEquipment({ deviceCode: 'E', deviceName: 'n', longitude: 0, latitude: 0 }).longitude === 0, '真 0 坐标照原样带出(是否上图由后端 renderable 决定)')
  165. // ---- 监测点详情(字段名照抄 monitoring-point-detail-demo.html)----
  166. const pointDetail = toMonitoringPointDetail({
  167. monitoringPointCode: 'MP-002',
  168. monitoringPointName: '燃气调压站监测点',
  169. pointStatus: 'ALARM',
  170. statusLabel: '报警',
  171. statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-001', reason: '压力超高' }],
  172. deviceStatistics: { totalDeviceCount: 2, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 },
  173. devices: [
  174. { deviceCode: 'EQ-001', deviceName: '供水压力计-001', deviceTypeName: '压力计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['压力超高'], statusTime: '2026-09-02T09:59:00+08:00' },
  175. { deviceCode: 'EQ-002', deviceName: '智能井盖-002', deviceTypeName: '智能井盖', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },
  176. ],
  177. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  178. statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }, { status: 'NORMAL', label: '正常', priority: 4 }],
  179. })
  180. check('B6-point-detail', pointDetail.code === 'MP-002' && pointDetail.statistics.total === 2 && pointDetail.statusReasons[0].reason === '压力超高')
  181. check('B6-device-order-kept', pointDetail.devices.map((d) => d.code).join(',') === 'EQ-001,EQ-002', '设备卡片顺序由后端决定(票 10 验收)')
  182. check('B6-dictionary-priority', pointDetail.statusDictionary[0].priority === 1 && pointDetail.statusDictionary.length === 2, '状态字典带后端优先级(票 09 用)')
  183. // ---- 工单七态 ----
  184. check('B7-seven-states', WORK_ORDER_STATES.length === 7 && WORK_ORDER_STATES.map((s) => s.code).join('') === '1234567')
  185. check('B7-closed-terminal', WORK_ORDER_STATES.filter((s) => s.terminal).map((s) => s.code).join(',') === '6,7', '只有已结案/已取消是终态')
  186. const order = toWorkOrder({ workOrderCode: 'WO-1', stateCode: 4, stateName: '处理中', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: 'ALM-1' } })
  187. check('B7-work-order', order.code === 'WO-1' && order.stateCode === 4 && order.realFinishTime === '' && order.event.source === 'ALARM', '未结案 realFinishTime 为 null(本来就没有,不算缺失)')
  188. // 状态名缺失时用字典补,不显示裸数字。
  189. const degradedOrder = toWorkOrder({ workOrderCode: 'WO-2', stateCode: 3 })
  190. check('B7-state-name-from-dictionary', degradedOrder.stateName === '已接单', `→ ${degradedOrder.stateName}`)
  191. // 七态分布:缺失补 0 是真实计数;字典外的键不丢。
  192. const distribution = toStateDistribution({ 4: 2, 6: 1, 99: 5 })
  193. check('B8-distribution-fills-zero', distribution.distribution[1] === 0 && distribution.distribution[4] === 2)
  194. check('B8-distribution-total', distribution.total === 3, '总数只算七态内的(3),不把未知键混进来')
  195. check('B8-unknown-kept', distribution.unknown.length === 1 && distribution.unknown[0].key === '99', '字典外的键单独列出,不静默丢')
  196. check('B8-closed-code', WORK_ORDER_CLOSED_CODE === 6)
  197. // 时间线:按 operatedAt 升序 + 回退动作如实带出(票 12:不是独立终态)。
  198. const timeline = toWorkOrderTimeline({
  199. workOrder: { workOrderCode: 'WO-3', stateCode: 4 },
  200. timelineEntries: [
  201. { logId: 3, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '李四', operatedAt: '2026-09-02T11:00:00+08:00', stateCode: 4, stateName: '处理中' },
  202. { logId: 1, operationName: '创建工单', operationDesc: '待派单', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 1, stateName: '待派单' },
  203. { logId: 2, operationName: '提交验收', operationDesc: '待验收', operatorName: '张三', operatedAt: '2026-09-02T10:00:00+08:00', stateCode: 5, stateName: '待验收' },
  204. ],
  205. })
  206. check('B9-timeline-sorted', timeline.entries.map((e) => e.operationName).join(',') === '创建工单,提交验收,验收驳回', '轨迹按时间升序(后端顺序不保证)')
  207. check('B9-rollback-is-action', timeline.entries[2].stateCode === 4 && timeline.entries[2].operationName === '验收驳回', '验收驳回是回到「处理中」的动作,不是第八态')
  208. // ---- 事件(字段名照抄 event-read-demo.html)----
  209. const eventRead = toEventRead({
  210. deviceCode: 'TC-1',
  211. deviceTypeName: '污水环境监测设备',
  212. state: 'READY',
  213. stateReason: 'READY',
  214. 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' }],
  215. 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' }],
  216. alarmWarningRelations: [{ relationId: 'REL-1', warningNo: 'WRN-1', alarmId: 'ALM-1', relatedAt: '2026-09-02T10:00:00+08:00' }],
  217. })
  218. check('B10-families', eventRead.warnings[0].family === EVENT_FAMILY.WARNING && eventRead.alarms[0].family === EVENT_FAMILY.ALARM)
  219. check('B10-unified-code', eventRead.warnings[0].code === 'WRN-1' && eventRead.alarms[0].code === 'ALM-1', '两类事件统一成 code,列表不必分支')
  220. check('B10-alarm-keeps-rule-code', eventRead.alarms[0].ruleCode === 'WS_ENV_HIGH', '报警的 warningCode 是规则编码,另存避免与事件编码混淆')
  221. check('B10-threshold-on-alarm', eventRead.alarms[0].thresholdMaxValue === 50 && eventRead.warnings[0].thresholdMaxValue === null, '阈值只有报警有,预警不得伪造')
  222. check('B10-relation', eventRead.relations[0].id === 'REL-1')
  223. check('B10-family-switch', eventsOfFamily(eventRead, EVENT_FAMILY.ALARM).length === 1 && eventsOfFamily(eventRead, EVENT_FAMILY.WARNING).length === 1 && allEvents(eventRead).length === 2)
  224. check('B10-sort-desc', sortByEventTimeDesc(allEvents(eventRead))[0].code === 'ALM-1', '最新在前(票 13 默认最新 10-15 条)')
  225. // 等级配色:认不出就不猜。
  226. const unknownColor = toEventRead({ warnings: [{ warningNo: 'W', level: 9, levelColor: 'PURPLE' }], alarms: [] }).warnings[0]
  227. check('B11-unknown-level-color', unknownColor.levelColor === '' && unknownColor.level === 9, '认不出的配色回空串,不自造颜色')
  228. // ---- 新鲜度 ----
  229. const fresh = toFreshness({ updatedAt: '2026-09-02T09:59:50+08:00', freshness: 'CURRENT', calibrationPeriodSeconds: 60, missedCycles: 0, dataAgeSeconds: 5, retained: false })
  230. const expired = toFreshness({ freshness: 'EXPIRED', staleReason: 'TWO_UPDATE_CYCLES_MISSED', missedCycles: 2, dataAgeSeconds: 120, retained: true })
  231. const blank = toFreshness({ freshness: 'CURRENT' })
  232. check('B12-expired-by-backend', isExpired(expired) === true && isExpired(fresh) === false)
  233. check('B12-expired-fallback', isExpired({ missedCycles: EXPIRED_MISSED_CYCLES }) === true, '后端没给 freshness 时才用 missedCycles 兜底')
  234. check('B12-retained-has-value', hasValue(expired) === true && hasValue(toFreshness({ freshness: 'EMPTY' })) === false)
  235. check('B12-age-placeholder', dataAgeText(blank) === '—' && dataAgeText(fresh) === '5 秒前' && dataAgeText(expired) === '2 分钟前', '缺时间显示占位符,不显示「0 秒」')
  236. // ---- 设备详情聚合:分区独立 ----
  237. const aggregate = toEquipmentDetailAggregate({
  238. deviceCode: 'TC-1',
  239. aggregateState: 'PARTIAL',
  240. archive: { state: 'FAILED', reason: 'READ_FAILURE', message: '设备档案数据暂不可用', data: null },
  241. metrics: { state: 'SUCCESS', reason: 'SUCCESS', data: { deviceCode: 'TC-1', state: 'CURRENT', metrics: [{ metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L' }] } },
  242. warnings: { state: 'SUCCESS', reason: 'SUCCESS', data: [] },
  243. alarms: { state: 'EMPTY', reason: 'NO_EVENTS', data: null },
  244. workOrders: { state: 'STALE', reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] },
  245. })
  246. check('B13-sections-all-present', EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]), '八个分区都在(后端没给的分区也留位)')
  247. check('B13-failed-isolated', aggregate.sections.archive.state === SECTION_STATE.FAILED && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '档案失败不影响指标成功(票 11 验收第 3 条)')
  248. check('B13-failed-keeps-message', aggregate.sections.archive.reason === '设备档案数据暂不可用')
  249. check('B13-empty-distinct', aggregate.sections.alarms.state === SECTION_STATE.EMPTY && aggregate.sections.alarms.data === null)
  250. check('B13-stale-retains', aggregate.sections.workOrders.state === SECTION_STATE.STALE && aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1', '过期分区保留旧值')
  251. check('B13-missing-section', aggregate.sections.timelines.state === SECTION_STATE.FAILED, '后端整段缺失时该分区算失败,不算成功')
  252. check('B13-metric-zero', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'FLOW', value: 0, unit: 'm³/h' }] } }).data.metrics[0].value === 0, '指标真 0 不得被当成缺失')
  253. check('B13-metric-missing-null', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'COD', metricName: 'COD' }] } }).data.metrics[0].value === null, '缺 value 显示占位符,不显示虚假零值')
  254. check('B13-aggregate-state-not-authoritative', aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '界面分区状态以各分区自己的为准')
  255. // 未知分区原样带出,不静默丢。
  256. check('B13-unknown-section-kept', toSection('brandNew', { state: 'SUCCESS', data: { any: 1 } }).data.any === 1)
  257. }
  258. // ================================================================ C 契约化 mock
  259. {
  260. const handlers = { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest }
  261. const register = (options) => registerCockpitMocks({ ...options, handlers })
  262. // ---- C1 成功样例:三个领域模块都能取到领域对象 ----
  263. register({ scenario: 'success' })
  264. // 票 08 起 mock 也按后端真实语义分页(分页窗口横跨监测点与未关联设备、第 2 页起要带快照),
  265. // 所以这里取的是**第 1 页**:它只是一条总序列里的一段,不再是"一次拿到全部"。
  266. const snapshot = await fetchMapSnapshot({ page: 1, pageSize: 2, config: builtinConfig })
  267. check(
  268. 'C1-map-success',
  269. snapshot.state === SECTION_STATE.SUCCESS && snapshot.data.points.length + snapshot.data.equipment.length === 2,
  270. JSON.stringify({ state: snapshot.state, points: snapshot.data.points.length, equipment: snapshot.data.equipment.length }),
  271. )
  272. check(
  273. 'C1-map-snapshot-locked',
  274. snapshot.data.snapshotAt !== '' && snapshot.data.complete === false,
  275. JSON.stringify({ snapshotAt: snapshot.data.snapshotAt, complete: snapshot.data.complete, total: snapshot.data.total }),
  276. )
  277. // 后续页带着第 1 页的快照请求:它是同一次加载的一部分(票 08 验收第 3 条)。
  278. const snapshotPage3 = await fetchMapSnapshot({ page: 3, pageSize: 2, snapshotAt: snapshot.data.snapshotAt, config: builtinConfig })
  279. check(
  280. 'C1-map-page-locked-to-snapshot',
  281. snapshotPage3.state === SECTION_STATE.SUCCESS && snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt && snapshotPage3.data.complete === true,
  282. JSON.stringify({ sameSnapshot: snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt, complete: snapshotPage3.data.complete }),
  283. )
  284. const aggregate = await fetchEquipmentDetailAggregate('TC-PS-WS-202609020012', { config: builtinConfig })
  285. check('C1-aggregate-success', aggregate.state === SECTION_STATE.SUCCESS && aggregate.data.sections.archive.data.name === '污水环境监测设备')
  286. check('C1-aggregate-substates', aggregate.data.sections.metrics.state === SECTION_STATE.SUCCESS && aggregate.data.sections.alarms.data[0].code === 'ALM-20260902-001')
  287. const point = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig })
  288. check('C1-point-success', point.state === SECTION_STATE.SUCCESS && point.data.code === 'MP-002' && point.data.devices.length === 2)
  289. check('C1-point-path-param', point.state === SECTION_STATE.SUCCESS, '模板路径 /monitoring-points/* 命中(带路径参数的接口也要能 mock)')
  290. const events = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig })
  291. check('C1-events-success', events.state === SECTION_STATE.SUCCESS && allEvents(events.data).length === 2)
  292. const orders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig })
  293. check('C1-work-orders-success', orders.state === SECTION_STATE.SUCCESS && orders.data.orders.length === 1)
  294. const freshness = await fetchFreshnessSection('TC-PS-WS-202609020012', { config: builtinConfig })
  295. check('C1-freshness-success', freshness.state === SECTION_STATE.SUCCESS && freshness.data.freshness === 'CURRENT')
  296. // ---- C2 空数据样例 ----
  297. register({ scenario: 'empty' })
  298. const emptyAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  299. check('C2-empty-aggregate', emptyAggregate.state === SECTION_STATE.SUCCESS && emptyAggregate.data.sections.archive.state === SECTION_STATE.EMPTY, '整体取到了,但每个分区各自是「无数据」')
  300. const emptyOrders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig })
  301. check('C2-empty-orders', emptyOrders.state === SECTION_STATE.EMPTY && emptyOrders.data === null, '空列表分区不得带空数组冒充数据')
  302. const emptyFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
  303. check('C2-empty-freshness', emptyFreshness.state === SECTION_STATE.EMPTY)
  304. // ---- C3 字段缺失样例 ----
  305. register({ scenario: 'degraded' })
  306. const degradedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  307. const degradedArchive = degradedAggregate.data.sections.archive
  308. check('C3-degraded-archive', degradedArchive.state === SECTION_STATE.SUCCESS && degradedArchive.data.code === 'TC-1' && degradedArchive.data.name === '', '缺字段的分区仍算成功,只是字段为空')
  309. check('C3-degraded-missing-recorded', degradedArchive.data.missing.includes('archive.deviceName'), JSON.stringify(degradedArchive.data.missing))
  310. check('C3-degraded-order-name', degradedAggregate.data.sections.workOrders.data[0].stateName === '已接单', '工单状态名缺失时用七态字典补')
  311. const degradedFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
  312. check('C3-degraded-age', dataAgeText(degradedFreshness.data) === '—', '缺 dataAgeSeconds 时显示占位符')
  313. const degradedPoint = await fetchMonitoringPointDetail('MP-DEGRADED', { config: builtinConfig })
  314. check('C3-degraded-point', degradedPoint.state === SECTION_STATE.SUCCESS && degradedPoint.data.code === 'MP-DEGRADED', '点位记录缺字段仍算成功(缺字段 ≠ 空记录)')
  315. // ---- C4 失败样例 ----
  316. register({ scenario: 'failed' })
  317. const failedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  318. check('C4-failed-aggregate', failedAggregate.state === SECTION_STATE.FAILED && failedAggregate.data === null, '处理器抛出 → 分区失败,不抛给页面')
  319. const failedSnapshot = await fetchMapSnapshot({ config: builtinConfig })
  320. check('C4-failed-snapshot', failedSnapshot.state === SECTION_STATE.FAILED && failedSnapshot.reason === REQUEST_REASON.UNAVAILABLE)
  321. // ---- C5 过期样例 ----
  322. register({ scenario: 'stale' })
  323. const staleAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  324. check('C5-stale-metrics', staleAggregate.data.sections.metrics.state === SECTION_STATE.STALE && staleAggregate.data.sections.metrics.retained === true, '过期分区带 retained,界面须显示过期标识')
  325. check('C5-stale-keeps-other', staleAggregate.data.sections.archive.state === SECTION_STATE.SUCCESS, '过期不牵连其它分区')
  326. const staleFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
  327. check('C5-stale-freshness', staleFreshness.state === SECTION_STATE.STALE && staleFreshness.data.freshness === 'EXPIRED' && isExpired(staleFreshness.data))
  328. // ---- C6 401:只坏一个分区,其余照常成功(票 05 验收第 6 条)----
  329. register({ scenario: 'unauthorized', onlyPath: '/equipments/*/detail-aggregate' })
  330. const deniedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  331. const okSnapshot = await fetchMapSnapshot({ config: builtinConfig })
  332. const okFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
  333. check('C6-401-isolated', deniedAggregate.state === SECTION_STATE.UNAVAILABLE && deniedAggregate.reason === REQUEST_REASON.UNAUTHORIZED, JSON.stringify({ state: deniedAggregate.state, reason: deniedAggregate.reason }))
  334. check('C6-neighbours-survive', okSnapshot.state === SECTION_STATE.SUCCESS && okFreshness.state === SECTION_STATE.SUCCESS, '受影响分区之外照常成功(保留已成功分区)')
  335. check('C6-overall-partial', overallState({ a: deniedAggregate, b: okSnapshot, c: okFreshness }) === 'partial')
  336. check('C6-view-is-data-unavailable', sectionView(deniedAggregate).label === '数据不可用')
  337. // 前缀只坏一族接口。
  338. register({ scenario: 'unauthorized', prefix: '/equipments/' })
  339. const prefixMap = await fetchMapSnapshot({ config: builtinConfig })
  340. const prefixAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
  341. const prefixPoint = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig })
  342. check('C6-prefix-isolated', prefixAggregate.state === SECTION_STATE.UNAVAILABLE && prefixMap.state === SECTION_STATE.SUCCESS && prefixPoint.state === SECTION_STATE.SUCCESS, '前缀选中一族接口,其余分区与地图不受影响')
  343. // ---- C7 未注册接口:明确原因,不静默 ----
  344. resetMockTransportsForTest()
  345. const missing = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig })
  346. check('C7-unregistered', missing.state === SECTION_STATE.UNAVAILABLE && missing.reason === REQUEST_REASON.MOCK_MISSING)
  347. // ---- C8 真实模式:不带凭据的 mock 分支不泄漏,real 分支走同一适配层 ----
  348. register({ scenario: 'success' })
  349. let seenHeaders = null
  350. const realResult = await fetchMapSnapshot({
  351. config: realConfig,
  352. fetchImpl: async (url, init) => {
  353. seenHeaders = { url, headers: init.headers }
  354. return { ok: true, status: 200, text: async () => JSON.stringify({ page: 1, pageSize: 1, total: 0, complete: true, monitoringPoints: [], unassociatedEquipment: [] }) }
  355. },
  356. })
  357. check('C8-real-same-adapter', realResult.state === SECTION_STATE.EMPTY && realResult.mode === 'real', '真接口与 mock 走同一适配层与同一转换')
  358. check('C8-real-attaches-credential', seenHeaders.headers.Authorization === 'Bearer readonly', '凭据只在适配层附加(票 04 的接缝未被绕过)')
  359. check('C8-real-url', seenHeaders.url.startsWith('https://gw/api/cockpit/v1/map-snapshot'), seenHeaders.url)
  360. // 假 401 走真接口路径:分区变「数据不可用」,不抛异常。
  361. const realDenied = await fetchMapSnapshot({
  362. config: realConfig,
  363. fetchImpl: async () => ({ ok: false, status: 401, text: async () => '{"code":401}' }),
  364. })
  365. check('C8-real-401', realDenied.state === SECTION_STATE.UNAVAILABLE && realDenied.reason === REQUEST_REASON.UNAUTHORIZED)
  366. // 真接口 200 HTML(dev 回退)→ 坏载荷,不当成成功。
  367. const realHtml = await fetchMapSnapshot({
  368. config: realConfig,
  369. fetchImpl: async () => ({ ok: true, status: 200, text: async () => '<!doctype html><html></html>' }),
  370. })
  371. check('C8-real-bad-payload', realHtml.state === SECTION_STATE.FAILED && realHtml.reason === REQUEST_REASON.BAD_PAYLOAD)
  372. // ---- C9 查询串拼装 ----
  373. check('C9-query-empty', buildQuery({ a: '', b: null, c: undefined }) === '')
  374. 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' }))
  375. check('C9-query-array', buildQuery({ x: ['a', 'b'] }) === '?x=a&x=b')
  376. // 历史事件走 history 路径并带时间范围。
  377. register({ scenario: 'success' })
  378. let historyUrl = ''
  379. await fetchEvents(
  380. { deviceCode: 'TC-1', startTime: '2026-09-01T00:00:00+08:00', endTime: '2026-09-02T00:00:00+08:00' },
  381. { config: realConfig, fetchImpl: async (url) => { historyUrl = url; return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'READY', warnings: [], alarms: [] }) } } },
  382. )
  383. check('C9-history-path', historyUrl.includes('/events/history?') && historyUrl.includes('startTime='), historyUrl)
  384. let currentUrl = ''
  385. 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: [] }) } } })
  386. check('C9-current-path', currentUrl.includes('/events/current') && !currentUrl.includes('startTime='), currentUrl)
  387. // 缺编码:不发请求,直接给出可读原因。
  388. let called = 0
  389. const noCode = await fetchEquipmentDetailAggregate('', { config: realConfig, fetchImpl: async () => { called += 1; return { ok: true, status: 200, text: async () => '{}' } } })
  390. check('C9-no-code-no-request', called === 0 && noCode.state === SECTION_STATE.FAILED && noCode.reason.includes('编码'), noCode.reason)
  391. // ---- C10 foundation:地图帧不可绘制 → 空而不是成功 ----
  392. registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'EMPTY', renderable: false, reason: '驾驶舱基础数据为空' }, freshness: { status: 'FRESH' } }))
  393. const emptyFrame = await fetchFoundation({ config: builtinConfig })
  394. check('C10-foundation-empty-frame', emptyFrame.state === SECTION_STATE.EMPTY && emptyFrame.reason === '驾驶舱基础数据为空')
  395. registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'READY', renderable: true }, freshness: { status: 'FRESH' } }))
  396. const readyFrame = await fetchFoundation({ config: builtinConfig })
  397. check('C10-foundation-ready', readyFrame.state === SECTION_STATE.SUCCESS && readyFrame.data.freshness.status === 'FRESH')
  398. // ---- C11 WS 增量帧 → 领域事件(字段名两个都认)----
  399. 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: '浓度超限' } })
  400. check('C11-delta-online-frame', delta && delta.deviceCode === 'DEV001' && delta.status === 'ALARM' && delta.reason === '浓度超限', JSON.stringify(delta))
  401. const dtoDelta = toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, deviceCode: 'DEV002', data: { monitoringState: 'OFFLINE' } })
  402. check('C11-delta-dto-name', dtoDelta && dtoDelta.status === 'OFFLINE', '后端 DTO 用 monitoringState,线上帧用 status,两个都要认')
  403. check('C11-unknown-frame', toDeviceStatusEvent({ type: 'somethingElse' }) === null && toDeviceStatusEvent(null) === null, '认不出的帧回 null,不猜')
  404. check('C11-no-code', toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, data: { status: 'ALARM' } }) === null, '没有设备编码的增量帧无法回联点位,必须丢弃')
  405. check('C11-subscribed', toSubscribedEvent({ type: WS_MESSAGE.SUBSCRIBED, scope: 'all', serverUpdatedAt: 't' }).scope === 'all')
  406. // ---- C12 注册幂等:重复注册只覆盖不叠加 ----
  407. register({ scenario: 'success' })
  408. const firstPaths = getMockTransportPaths().length
  409. register({ scenario: 'success' })
  410. check('C12-idempotent', getMockTransportPaths().length === firstPaths, `${getMockTransportPaths().length} 个路径(两次注册后不翻倍)`)
  411. check('C12-covers-shared-domain', ['/map-snapshot', '/monitoring-points/*', '/equipments/*/detail-aggregate'].every((p) => getMockTransportPaths().includes(p)), getMockTransportPaths().join(', '))
  412. }
  413. // ================================================================ D 静态守卫(对抗式)
  414. {
  415. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  416. const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  417. // 页面与 store 不得出现后端裸字段名(票 05 验收第 4 条)。
  418. // 按键名清单查:这些是后端 JSON 里的字段,领域对象已全部改名。
  419. const BACKEND_FIELD_PATTERN = /\b(monitoringPoints|unassociatedEquipment|coordinateValidation|pointStatusCode|deviceStatistics|aggregateState|timelineEntries|alarmWarningRelations|warningNo|alarmId|stateDictionary|stateReason|missingCycles|dataAgeSeconds|calibrationPeriodSeconds|staleReason|workOrders|deviceStatus)\b/
  420. const pageAndStoreFiles = [
  421. 'CockpitShell.vue',
  422. 'components/MapCanvas.vue',
  423. 'components/CockpitPanel.vue',
  424. 'components/PanelPlaceholder.vue',
  425. 'components/StatusTag.vue',
  426. 'components/SectionStateTag.vue',
  427. 'components/CockpitIcon.vue',
  428. // 票 10/11 的详情浮层与设备详情分区(新增文件一律补进守卫面,否则新代码是检查不到的荒地)。
  429. 'components/PointDetailCard.vue',
  430. 'components/EquipmentDetailSections.vue',
  431. 'stores/uiStore.js',
  432. 'stores/filterStore.js',
  433. 'stores/detailStore.js',
  434. ]
  435. for (const file of pageAndStoreFiles) {
  436. const text = readModule(file)
  437. const found = BACKEND_FIELD_PATTERN.exec(text)
  438. check(`D1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段')
  439. }
  440. // 页面与 store 不碰凭据(票 04 立的规矩,票 05 不得破坏)。
  441. const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
  442. for (const file of pageAndStoreFiles) {
  443. check(`D2-${file}-no-credential`, !CREDENTIAL_PATTERN.test(readModule(file)), '页面与 store 不碰凭据')
  444. }
  445. // 领域模块不直接发请求:转换必须与传输分离(否则转换单测就得起网络)。
  446. const domainFiles = [
  447. 'domain/sectionState.js',
  448. 'domain/mapSummary.js',
  449. 'domain/pointDetail.js',
  450. 'domain/equipmentDetail.js',
  451. // 票 11:设备详情分区的界面视图(纯函数,不 import service、自己不发请求)。
  452. 'domain/equipmentDetailView.js',
  453. 'domain/workOrder.js',
  454. 'domain/events.js',
  455. 'domain/freshness.js',
  456. 'domain/fields.js',
  457. ]
  458. for (const file of domainFiles) {
  459. const text = readModule(file)
  460. check(`D3-${file}-pure`, !/\bfetch\s*\(|XMLHttpRequest/.test(text), '领域转换不自己发请求')
  461. }
  462. // domain 不得反向依赖 service(层次倒挂)。
  463. for (const file of domainFiles) {
  464. const text = readModule(file)
  465. check(`D4-${file}-no-service-import`, !/from '\.\.\/services\//.test(text), 'domain 不 import service')
  466. }
  467. // 登录跳转:票 05 的场景(含 401)不得引入任何登录入口。
  468. for (const file of [...pageAndStoreFiles, 'mocks/contractMocks.js', 'mocks/index.js', ...domainFiles]) {
  469. const text = readModule(file)
  470. check(`D5-${file}-no-login-redirect`, !/location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text), '不跳登录页')
  471. }
  472. }
  473. const failed = checks.filter((item) => !item.pass)
  474. console.log('')
  475. console.log(`T05 适配层检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  476. if (failed.length) {
  477. console.log('未通过:')
  478. for (const item of failed) console.log(` · ${item.name}`)
  479. process.exit(1)
  480. }
  481. console.log('T05 领域适配层全部通过。')