t42-warning-settings-contract.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /**
  2. * T42 契约守卫:预警设置面板消费**预警自身生命周期**契约(后端 `b047a60d`)。
  3. *
  4. * 守什么(票 42 验收):
  5. * 1. 预警族工单口径字段**原样 null**(不适用 ≠ 0 —— `numberish` 绝不造 0,
  6. * 视图显示占位符,领域层不得补假零条);
  7. * 2. 预警族四组分布(状态六桶/等级/行业/片区)直读透传,且 `sum(桶)==total` 自洽;
  8. * 3. 报警族保留工单口径(0–7 全铺、工单口径处置率)并**并存** `alarm_status` 分布;
  9. * 4. 空态:两族 EMPTY、已解除率「不适用」,分布仍全铺计数 0(不是没有数据)。
  10. *
  11. * 数值用基线卡片 §4.2 的**真实复验值**(2026-10-14 联调库:预警 31 / 报警 66)——
  12. * 守的是线上真实跑的那份契约,不是另编的玩具形状。mock(contractMocks)侧的同构
  13. * 由 t20 的 D8 端到端(同一领域函数消费 mock 载荷)覆盖。
  14. *
  15. * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t42-warning-settings-contract.mjs
  16. */
  17. import assert from 'node:assert/strict'
  18. import { toOverviewWarningSettings, alertSettingsPanel } from '../../../src/modules/cockpit/domain/overviewPanels.js'
  19. import { handlingRateView } from '../../../src/modules/cockpit/domain/warningResponsePanels.js'
  20. // ---- 基线卡片 §4.2 的真实载荷形状(LAST_7_DAYS 实测,2026-10-14) ----------------
  21. const REAL_PAYLOAD = {
  22. requestId: 't42', apiVersion: 'v1', readOnly: true, displayName: '预警设置',
  23. selectedMode: 'WARNING', timezone: 'Asia/Shanghai',
  24. range: { type: 'LAST_7_DAYS', start: '2026-10-07T12:16:17+08:00', end: '2026-10-14T12:16:17+08:00' },
  25. updatedAt: '2026-10-14T11:37:12+08:00',
  26. freshness: { status: 'CURRENT', reason: 'READ_ONLY_AGGREGATE', updatedAt: '2026-10-14T11:37:12+08:00', serverUpdatedAt: '2026-10-14T12:16:17+08:00' },
  27. warning: {
  28. eventFamily: 'WARNING', state: 'READY', stateReason: 'WARNINGS_RESOLVED', total: 31,
  29. handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null,
  30. handlingEfficiency: { numerator: 19, denominator: 31, value: '0.6129', state: 'AVAILABLE', reason: null },
  31. workOrderStates: null,
  32. statusDistribution: [
  33. { code: 'DRAFT', label: '草稿', count: 2 }, { code: 'PENDING', label: '待办', count: 4 },
  34. { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 4 },
  35. { code: 'HANDLED', label: '已处置', count: 2 }, { code: 'CLOSED', label: '已解除', count: 19 },
  36. ],
  37. levelDistribution: [
  38. { code: '1', label: '1级', count: 13 }, { code: '2', label: '2级', count: 5 },
  39. { code: '3', label: '3级', count: 1 }, { code: '4', label: '4级', count: 12 },
  40. ],
  41. industryDistribution: [
  42. { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 },
  43. { code: 'DRAINAGE', label: '排水', count: 24 }, { code: 'MANHOLE', label: '窨井', count: 1 },
  44. { code: 'UNKNOWN', label: '未知', count: 4 },
  45. ],
  46. districtDistribution: [
  47. { code: '0', label: '城南', count: 24 }, { code: '1', label: '城北', count: 1 },
  48. { code: '2', label: '太常', count: 2 }, { code: 'UNASSIGNED', label: '未分区', count: 4 },
  49. ],
  50. trend: [
  51. { bucket: '2026-10-10', total: 16, handled: 14, handlingEfficiency: { numerator: 14, denominator: 16, value: '0.8750', state: 'AVAILABLE', reason: null } },
  52. { bucket: '2026-10-09', total: 3, handled: 2, handlingEfficiency: { numerator: 2, denominator: 3, value: '0.6667', state: 'AVAILABLE', reason: null } },
  53. { bucket: '2026-10-14', total: 2, handled: 0, handlingEfficiency: { numerator: 0, denominator: 2, value: '0.0000', state: 'AVAILABLE', reason: null } },
  54. ],
  55. },
  56. alarm: {
  57. eventFamily: 'ALARM', state: 'READY', stateReason: 'EVENTS_RESOLVED', total: 66,
  58. handled: 64, pending: 0, cancelledCount: 0, unlinkedEventCount: 65, invalidWorkOrderStateCount: 0,
  59. handlingEfficiency: { numerator: 1, denominator: 5, value: '0.2000', state: 'AVAILABLE', reason: null },
  60. workOrderStates: { 0: 0, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 0, 7: 1 },
  61. statusDistribution: [
  62. { code: 'UNHANDLED', label: '未处理', count: 2 }, { code: 'HANDLED', label: '已处理', count: 64 },
  63. ],
  64. levelDistribution: [
  65. { code: '1', label: '1级', count: 0 }, { code: '2', label: '2级', count: 0 },
  66. { code: '3', label: '3级', count: 0 }, { code: '4', label: '4级', count: 66 },
  67. ],
  68. industryDistribution: [
  69. { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 0 },
  70. { code: 'DRAINAGE', label: '排水', count: 66 }, { code: 'MANHOLE', label: '窨井', count: 0 },
  71. ],
  72. districtDistribution: [
  73. { code: '0', label: '城南', count: 66 }, { code: '1', label: '城北', count: 0 },
  74. { code: '2', label: '太常', count: 0 }, { code: 'UNASSIGNED', label: '未分区', count: 0 },
  75. ],
  76. trend: [
  77. { bucket: '2026-10-10', total: 4, handled: 0, handlingEfficiency: { numerator: 0, denominator: 4, value: '0.0000', state: 'AVAILABLE', reason: null } },
  78. { bucket: '2026-10-12', total: 62, handled: 1, handlingEfficiency: { numerator: 1, denominator: 62, value: '0.0161', state: 'AVAILABLE', reason: null } },
  79. ],
  80. },
  81. mapFilter: { eventFamily: 'WARNING', label: '预警' },
  82. }
  83. // 空场景:两族都空;分布仍全铺计数 0(后端契约如此),工单口径字段按族各自缺席。
  84. const EMPTY_PAYLOAD = {
  85. ...REAL_PAYLOAD,
  86. updatedAt: '2026-10-14T12:00:00+08:00',
  87. warning: {
  88. ...REAL_PAYLOAD.warning, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
  89. handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_WARNINGS' },
  90. statusDistribution: REAL_PAYLOAD.warning.statusDistribution.map((row) => ({ ...row, count: 0 })),
  91. levelDistribution: REAL_PAYLOAD.warning.levelDistribution.map((row) => ({ ...row, count: 0 })),
  92. industryDistribution: REAL_PAYLOAD.warning.industryDistribution.map((row) => ({ ...row, count: 0 })),
  93. districtDistribution: REAL_PAYLOAD.warning.districtDistribution.map((row) => ({ ...row, count: 0 })),
  94. trend: [],
  95. },
  96. alarm: {
  97. ...REAL_PAYLOAD.alarm, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
  98. handled: 0, pending: 0, cancelledCount: 0, unlinkedEventCount: 0, invalidWorkOrderStateCount: 0,
  99. handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS' },
  100. workOrderStates: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 },
  101. statusDistribution: REAL_PAYLOAD.alarm.statusDistribution.map((row) => ({ ...row, count: 0 })),
  102. levelDistribution: REAL_PAYLOAD.alarm.levelDistribution.map((row) => ({ ...row, count: 0 })),
  103. industryDistribution: REAL_PAYLOAD.alarm.industryDistribution.map((row) => ({ ...row, count: 0 })),
  104. districtDistribution: REAL_PAYLOAD.alarm.districtDistribution.map((row) => ({ ...row, count: 0 })),
  105. trend: [],
  106. },
  107. }
  108. const section = { state: 'SUCCESS', loaded: true }
  109. const ready = (data) => ({ ...section, data })
  110. // ================================================================ 1 领域层
  111. const data = toOverviewWarningSettings(REAL_PAYLOAD)
  112. // 验收 1:预警族工单口径原样 null —— null 是"不适用",不是 0。
  113. assert.equal(data.warning.workOrderStates, null, '预警族 workOrderStates 必须原样 null(不补假零条)')
  114. assert.equal(data.warning.handled, null, '预警族 handled 必须原样 null')
  115. assert.equal(data.warning.pending, null, '预警族 pending 必须原样 null')
  116. assert.equal(data.warning.cancelledCount, null, '预警族 cancelledCount 必须原样 null')
  117. assert.equal(data.warning.unlinkedEventCount, null, '预警族 unlinkedEventCount 必须原样 null')
  118. assert.equal(data.warning.invalidWorkOrderStateCount, null, '预警族 invalidWorkOrderStateCount 必须原样 null')
  119. // 验收 2:四组分布直读透传且自洽(sum(桶)==total)。
  120. const sum = (rows) => rows.reduce((total, row) => total + row.count, 0)
  121. assert.equal(data.warning.statusDistribution.length, 6, '状态六桶全铺')
  122. assert.equal(sum(data.warning.statusDistribution), 31, '状态分布和=总数')
  123. assert.equal(sum(data.warning.levelDistribution), 31, '等级分布和=总数')
  124. assert.equal(sum(data.warning.industryDistribution), 31, '行业分布和=总数')
  125. assert.equal(sum(data.warning.districtDistribution), 31, '片区分布和=总数')
  126. assert.ok(data.warning.industryDistribution.some((row) => row.code === 'UNKNOWN' && row.label === '未知'), 'UNKNOWN 桶 label 原样透传')
  127. assert.ok(data.warning.districtDistribution.some((row) => row.code === 'UNASSIGNED' && row.label === '未分区'), 'UNASSIGNED 桶恒在')
  128. // 已解除率=已解除 ÷ 预警总数(19/31),语义与工单处置率分得开。
  129. assert.equal(data.warning.efficiency.numerator, 19)
  130. assert.equal(data.warning.efficiency.denominator, 31)
  131. assert.equal(data.warning.efficiency.handlingState, 'AVAILABLE')
  132. // 验收 3:报警族保留工单口径并并存自身生命周期。
  133. assert.ok(Array.isArray(data.alarm.workOrderStates) && data.alarm.workOrderStates.length === 8, '报警族工单 0–7 全铺')
  134. assert.equal(data.alarm.workOrderStates.find((row) => row.code === '5').count, 1, '报警族工单状态 5 计数来自后端')
  135. assert.equal(sum(data.alarm.statusDistribution), 66, '报警自身生命周期分布和=总数')
  136. assert.equal(data.alarm.statusDistribution.find((row) => row.code === 'HANDLED').count, 64, '报警已处理(alarm_status)')
  137. assert.equal(data.alarm.efficiency.numerator, 1, '报警处置率仍是工单口径(分子=状态 5 工单)')
  138. assert.equal(data.alarm.efficiency.denominator, 5, '报警处置率分母=状态 0–6 工单(7 张排除已取消 1 张)')
  139. // ================================================================ 2 视图层
  140. const view = alertSettingsPanel(data, ready(data))
  141. const kpiItems = view.blocks.flatMap((block) => block.items || [])
  142. const labels = view.blocks.map((block) => block.label).filter(Boolean)
  143. // 预警族 KPI 只剩 总数 + 已解除率(工单口径读数不再出现)。
  144. const warningKpiKeys = view.blocks[0].items.map((item) => item.key)
  145. assert.deepEqual(warningKpiKeys, ['WARNING-total', 'WARNING-rate'], '预警族 KPI=总数 + 已解除率')
  146. assert.equal(view.blocks[0].items.find((item) => item.key === 'WARNING-rate').label, '已解除率')
  147. // 空值显示占位符(不适用 ≠ 0):领域层 null 经 valueText 是 '—',
  148. // 这里直接断领域对象不携带 0(视图的 valueText 是既定纪律,t20 已守)。
  149. assert.notEqual(data.warning.handled, 0, 'null 不得被数成 0')
  150. // 分布块:预警四组 + 报警工单分布 + 报警四组;工单分布块只属于报警族。
  151. assert.ok(labels.includes('预警 · 生命周期状态'), '预警生命周期状态块')
  152. assert.equal(view.blocks.find((block) => block.label === '预警 · 生命周期状态').rows.length, 6, '状态六桶画满')
  153. assert.ok(labels.includes('预警 · 行业分布') && labels.includes('预警 · 片区分布'), '预警行业/片区分布块')
  154. assert.ok(labels.includes('报警 · 工单状态分布'), '报警工单分布块保留(工单口径)')
  155. assert.equal(view.blocks.find((block) => block.label === '报警 · 工单状态分布').rows.length, 8, '报警工单 0–7 画满')
  156. assert.ok(labels.includes('报警 · 生命周期状态'), '报警生命周期状态块并存')
  157. assert.equal(view.blocks.filter((block) => (block.label || '').startsWith('预警 · 工单')).length, 0, '预警族没有工单口径块')
  158. // 已解除率的值与 hint(票 20 单源判据:不适用不是 0%)。
  159. const rate = view.blocks[0].items.find((item) => item.key === 'WARNING-rate')
  160. assert.equal(rate.value, '61.3%', '已解除率 19/31 显示 61.3%')
  161. assert.equal(rate.hint, '已解除 19 / 预警总数 31', 'hint 写明分子分母,不冒充工单处置率')
  162. // ================================================================ 3 空态
  163. const emptyData = toOverviewWarningSettings(EMPTY_PAYLOAD)
  164. const emptyView = alertSettingsPanel(emptyData, ready(emptyData))
  165. assert.equal(emptyData.warning.state, 'EMPTY')
  166. assert.equal(handlingRateView(emptyData.warning.efficiency), '不适用', '空态已解除率=不适用(不是 0.0%)')
  167. assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').value, '不适用')
  168. assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').hint, '当前范围没有预警,已解除率不适用')
  169. assert.equal(emptyData.warning.workOrderStates, null, '空态下预警族工单口径仍是 null(族口径不随数据多少变)')
  170. assert.equal(sum(emptyData.warning.statusDistribution), 0, '空态分布全铺计数 0(真实计数,不是缺桶)')
  171. assert.equal(emptyData.alarm.efficiency.handlingReason, 'NO_ELIGIBLE_WORK_ORDERS', '报警族空态原因码透传')
  172. console.log('T42 warning-settings lifecycle contract: null-fidelity, four-distribution self-consistency, alarm work-order retention, empty-state not-applicable passed')