|
@@ -0,0 +1,185 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * T42 契约守卫:预警设置面板消费**预警自身生命周期**契约(后端 `b047a60d`)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 守什么(票 42 验收):
|
|
|
|
|
+ * 1. 预警族工单口径字段**原样 null**(不适用 ≠ 0 —— `numberish` 绝不造 0,
|
|
|
|
|
+ * 视图显示占位符,领域层不得补假零条);
|
|
|
|
|
+ * 2. 预警族四组分布(状态六桶/等级/行业/片区)直读透传,且 `sum(桶)==total` 自洽;
|
|
|
|
|
+ * 3. 报警族保留工单口径(0–7 全铺、工单口径处置率)并**并存** `alarm_status` 分布;
|
|
|
|
|
+ * 4. 空态:两族 EMPTY、已解除率「不适用」,分布仍全铺计数 0(不是没有数据)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 数值用基线卡片 §4.2 的**真实复验值**(2026-10-14 联调库:预警 31 / 报警 66)——
|
|
|
|
|
+ * 守的是线上真实跑的那份契约,不是另编的玩具形状。mock(contractMocks)侧的同构
|
|
|
|
|
+ * 由 t20 的 D8 端到端(同一领域函数消费 mock 载荷)覆盖。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t42-warning-settings-contract.mjs
|
|
|
|
|
+ */
|
|
|
|
|
+import assert from 'node:assert/strict'
|
|
|
|
|
+import { toOverviewWarningSettings, alertSettingsPanel } from '../../../src/modules/cockpit/domain/overviewPanels.js'
|
|
|
|
|
+import { handlingRateView } from '../../../src/modules/cockpit/domain/warningResponsePanels.js'
|
|
|
|
|
+
|
|
|
|
|
+// ---- 基线卡片 §4.2 的真实载荷形状(LAST_7_DAYS 实测,2026-10-14) ----------------
|
|
|
|
|
+const REAL_PAYLOAD = {
|
|
|
|
|
+ requestId: 't42', apiVersion: 'v1', readOnly: true, displayName: '预警设置',
|
|
|
|
|
+ selectedMode: 'WARNING', timezone: 'Asia/Shanghai',
|
|
|
|
|
+ range: { type: 'LAST_7_DAYS', start: '2026-10-07T12:16:17+08:00', end: '2026-10-14T12:16:17+08:00' },
|
|
|
|
|
+ updatedAt: '2026-10-14T11:37:12+08:00',
|
|
|
|
|
+ freshness: { status: 'CURRENT', reason: 'READ_ONLY_AGGREGATE', updatedAt: '2026-10-14T11:37:12+08:00', serverUpdatedAt: '2026-10-14T12:16:17+08:00' },
|
|
|
|
|
+ warning: {
|
|
|
|
|
+ eventFamily: 'WARNING', state: 'READY', stateReason: 'WARNINGS_RESOLVED', total: 31,
|
|
|
|
|
+ handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null,
|
|
|
|
|
+ handlingEfficiency: { numerator: 19, denominator: 31, value: '0.6129', state: 'AVAILABLE', reason: null },
|
|
|
|
|
+ workOrderStates: null,
|
|
|
|
|
+ statusDistribution: [
|
|
|
|
|
+ { code: 'DRAFT', label: '草稿', count: 2 }, { code: 'PENDING', label: '待办', count: 4 },
|
|
|
|
|
+ { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 4 },
|
|
|
|
|
+ { code: 'HANDLED', label: '已处置', count: 2 }, { code: 'CLOSED', label: '已解除', count: 19 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ levelDistribution: [
|
|
|
|
|
+ { code: '1', label: '1级', count: 13 }, { code: '2', label: '2级', count: 5 },
|
|
|
|
|
+ { code: '3', label: '3级', count: 1 }, { code: '4', label: '4级', count: 12 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ industryDistribution: [
|
|
|
|
|
+ { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 },
|
|
|
|
|
+ { code: 'DRAINAGE', label: '排水', count: 24 }, { code: 'MANHOLE', label: '窨井', count: 1 },
|
|
|
|
|
+ { code: 'UNKNOWN', label: '未知', count: 4 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ districtDistribution: [
|
|
|
|
|
+ { code: '0', label: '城南', count: 24 }, { code: '1', label: '城北', count: 1 },
|
|
|
|
|
+ { code: '2', label: '太常', count: 2 }, { code: 'UNASSIGNED', label: '未分区', count: 4 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ trend: [
|
|
|
|
|
+ { bucket: '2026-10-10', total: 16, handled: 14, handlingEfficiency: { numerator: 14, denominator: 16, value: '0.8750', state: 'AVAILABLE', reason: null } },
|
|
|
|
|
+ { bucket: '2026-10-09', total: 3, handled: 2, handlingEfficiency: { numerator: 2, denominator: 3, value: '0.6667', state: 'AVAILABLE', reason: null } },
|
|
|
|
|
+ { bucket: '2026-10-14', total: 2, handled: 0, handlingEfficiency: { numerator: 0, denominator: 2, value: '0.0000', state: 'AVAILABLE', reason: null } },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ alarm: {
|
|
|
|
|
+ eventFamily: 'ALARM', state: 'READY', stateReason: 'EVENTS_RESOLVED', total: 66,
|
|
|
|
|
+ handled: 64, pending: 0, cancelledCount: 0, unlinkedEventCount: 65, invalidWorkOrderStateCount: 0,
|
|
|
|
|
+ handlingEfficiency: { numerator: 1, denominator: 5, value: '0.2000', state: 'AVAILABLE', reason: null },
|
|
|
|
|
+ workOrderStates: { 0: 0, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 0, 7: 1 },
|
|
|
|
|
+ statusDistribution: [
|
|
|
|
|
+ { code: 'UNHANDLED', label: '未处理', count: 2 }, { code: 'HANDLED', label: '已处理', count: 64 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ levelDistribution: [
|
|
|
|
|
+ { code: '1', label: '1级', count: 0 }, { code: '2', label: '2级', count: 0 },
|
|
|
|
|
+ { code: '3', label: '3级', count: 0 }, { code: '4', label: '4级', count: 66 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ industryDistribution: [
|
|
|
|
|
+ { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 0 },
|
|
|
|
|
+ { code: 'DRAINAGE', label: '排水', count: 66 }, { code: 'MANHOLE', label: '窨井', count: 0 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ districtDistribution: [
|
|
|
|
|
+ { code: '0', label: '城南', count: 66 }, { code: '1', label: '城北', count: 0 },
|
|
|
|
|
+ { code: '2', label: '太常', count: 0 }, { code: 'UNASSIGNED', label: '未分区', count: 0 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ trend: [
|
|
|
|
|
+ { bucket: '2026-10-10', total: 4, handled: 0, handlingEfficiency: { numerator: 0, denominator: 4, value: '0.0000', state: 'AVAILABLE', reason: null } },
|
|
|
|
|
+ { bucket: '2026-10-12', total: 62, handled: 1, handlingEfficiency: { numerator: 1, denominator: 62, value: '0.0161', state: 'AVAILABLE', reason: null } },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ mapFilter: { eventFamily: 'WARNING', label: '预警' },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 空场景:两族都空;分布仍全铺计数 0(后端契约如此),工单口径字段按族各自缺席。
|
|
|
|
|
+const EMPTY_PAYLOAD = {
|
|
|
|
|
+ ...REAL_PAYLOAD,
|
|
|
|
|
+ updatedAt: '2026-10-14T12:00:00+08:00',
|
|
|
|
|
+ warning: {
|
|
|
|
|
+ ...REAL_PAYLOAD.warning, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
|
|
|
|
|
+ handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_WARNINGS' },
|
|
|
|
|
+ statusDistribution: REAL_PAYLOAD.warning.statusDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ levelDistribution: REAL_PAYLOAD.warning.levelDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ industryDistribution: REAL_PAYLOAD.warning.industryDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ districtDistribution: REAL_PAYLOAD.warning.districtDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ trend: [],
|
|
|
|
|
+ },
|
|
|
|
|
+ alarm: {
|
|
|
|
|
+ ...REAL_PAYLOAD.alarm, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
|
|
|
|
|
+ handled: 0, pending: 0, cancelledCount: 0, unlinkedEventCount: 0, invalidWorkOrderStateCount: 0,
|
|
|
|
|
+ handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS' },
|
|
|
|
|
+ workOrderStates: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 },
|
|
|
|
|
+ statusDistribution: REAL_PAYLOAD.alarm.statusDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ levelDistribution: REAL_PAYLOAD.alarm.levelDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ industryDistribution: REAL_PAYLOAD.alarm.industryDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ districtDistribution: REAL_PAYLOAD.alarm.districtDistribution.map((row) => ({ ...row, count: 0 })),
|
|
|
|
|
+ trend: [],
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const section = { state: 'SUCCESS', loaded: true }
|
|
|
|
|
+const ready = (data) => ({ ...section, data })
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ 1 领域层
|
|
|
|
|
+const data = toOverviewWarningSettings(REAL_PAYLOAD)
|
|
|
|
|
+
|
|
|
|
|
+// 验收 1:预警族工单口径原样 null —— null 是"不适用",不是 0。
|
|
|
|
|
+assert.equal(data.warning.workOrderStates, null, '预警族 workOrderStates 必须原样 null(不补假零条)')
|
|
|
|
|
+assert.equal(data.warning.handled, null, '预警族 handled 必须原样 null')
|
|
|
|
|
+assert.equal(data.warning.pending, null, '预警族 pending 必须原样 null')
|
|
|
|
|
+assert.equal(data.warning.cancelledCount, null, '预警族 cancelledCount 必须原样 null')
|
|
|
|
|
+assert.equal(data.warning.unlinkedEventCount, null, '预警族 unlinkedEventCount 必须原样 null')
|
|
|
|
|
+assert.equal(data.warning.invalidWorkOrderStateCount, null, '预警族 invalidWorkOrderStateCount 必须原样 null')
|
|
|
|
|
+
|
|
|
|
|
+// 验收 2:四组分布直读透传且自洽(sum(桶)==total)。
|
|
|
|
|
+const sum = (rows) => rows.reduce((total, row) => total + row.count, 0)
|
|
|
|
|
+assert.equal(data.warning.statusDistribution.length, 6, '状态六桶全铺')
|
|
|
|
|
+assert.equal(sum(data.warning.statusDistribution), 31, '状态分布和=总数')
|
|
|
|
|
+assert.equal(sum(data.warning.levelDistribution), 31, '等级分布和=总数')
|
|
|
|
|
+assert.equal(sum(data.warning.industryDistribution), 31, '行业分布和=总数')
|
|
|
|
|
+assert.equal(sum(data.warning.districtDistribution), 31, '片区分布和=总数')
|
|
|
|
|
+assert.ok(data.warning.industryDistribution.some((row) => row.code === 'UNKNOWN' && row.label === '未知'), 'UNKNOWN 桶 label 原样透传')
|
|
|
|
|
+assert.ok(data.warning.districtDistribution.some((row) => row.code === 'UNASSIGNED' && row.label === '未分区'), 'UNASSIGNED 桶恒在')
|
|
|
|
|
+// 已解除率=已解除 ÷ 预警总数(19/31),语义与工单处置率分得开。
|
|
|
|
|
+assert.equal(data.warning.efficiency.numerator, 19)
|
|
|
|
|
+assert.equal(data.warning.efficiency.denominator, 31)
|
|
|
|
|
+assert.equal(data.warning.efficiency.handlingState, 'AVAILABLE')
|
|
|
|
|
+
|
|
|
|
|
+// 验收 3:报警族保留工单口径并并存自身生命周期。
|
|
|
|
|
+assert.ok(Array.isArray(data.alarm.workOrderStates) && data.alarm.workOrderStates.length === 8, '报警族工单 0–7 全铺')
|
|
|
|
|
+assert.equal(data.alarm.workOrderStates.find((row) => row.code === '5').count, 1, '报警族工单状态 5 计数来自后端')
|
|
|
|
|
+assert.equal(sum(data.alarm.statusDistribution), 66, '报警自身生命周期分布和=总数')
|
|
|
|
|
+assert.equal(data.alarm.statusDistribution.find((row) => row.code === 'HANDLED').count, 64, '报警已处理(alarm_status)')
|
|
|
|
|
+assert.equal(data.alarm.efficiency.numerator, 1, '报警处置率仍是工单口径(分子=状态 5 工单)')
|
|
|
|
|
+assert.equal(data.alarm.efficiency.denominator, 5, '报警处置率分母=状态 0–6 工单(7 张排除已取消 1 张)')
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ 2 视图层
|
|
|
|
|
+const view = alertSettingsPanel(data, ready(data))
|
|
|
|
|
+const kpiItems = view.blocks.flatMap((block) => block.items || [])
|
|
|
|
|
+const labels = view.blocks.map((block) => block.label).filter(Boolean)
|
|
|
|
|
+
|
|
|
|
|
+// 预警族 KPI 只剩 总数 + 已解除率(工单口径读数不再出现)。
|
|
|
|
|
+const warningKpiKeys = view.blocks[0].items.map((item) => item.key)
|
|
|
|
|
+assert.deepEqual(warningKpiKeys, ['WARNING-total', 'WARNING-rate'], '预警族 KPI=总数 + 已解除率')
|
|
|
|
|
+assert.equal(view.blocks[0].items.find((item) => item.key === 'WARNING-rate').label, '已解除率')
|
|
|
|
|
+// 空值显示占位符(不适用 ≠ 0):领域层 null 经 valueText 是 '—',
|
|
|
|
|
+// 这里直接断领域对象不携带 0(视图的 valueText 是既定纪律,t20 已守)。
|
|
|
|
|
+assert.notEqual(data.warning.handled, 0, 'null 不得被数成 0')
|
|
|
|
|
+
|
|
|
|
|
+// 分布块:预警四组 + 报警工单分布 + 报警四组;工单分布块只属于报警族。
|
|
|
|
|
+assert.ok(labels.includes('预警 · 生命周期状态'), '预警生命周期状态块')
|
|
|
|
|
+assert.equal(view.blocks.find((block) => block.label === '预警 · 生命周期状态').rows.length, 6, '状态六桶画满')
|
|
|
|
|
+assert.ok(labels.includes('预警 · 行业分布') && labels.includes('预警 · 片区分布'), '预警行业/片区分布块')
|
|
|
|
|
+assert.ok(labels.includes('报警 · 工单状态分布'), '报警工单分布块保留(工单口径)')
|
|
|
|
|
+assert.equal(view.blocks.find((block) => block.label === '报警 · 工单状态分布').rows.length, 8, '报警工单 0–7 画满')
|
|
|
|
|
+assert.ok(labels.includes('报警 · 生命周期状态'), '报警生命周期状态块并存')
|
|
|
|
|
+assert.equal(view.blocks.filter((block) => (block.label || '').startsWith('预警 · 工单')).length, 0, '预警族没有工单口径块')
|
|
|
|
|
+
|
|
|
|
|
+// 已解除率的值与 hint(票 20 单源判据:不适用不是 0%)。
|
|
|
|
|
+const rate = view.blocks[0].items.find((item) => item.key === 'WARNING-rate')
|
|
|
|
|
+assert.equal(rate.value, '61.3%', '已解除率 19/31 显示 61.3%')
|
|
|
|
|
+assert.equal(rate.hint, '已解除 19 / 预警总数 31', 'hint 写明分子分母,不冒充工单处置率')
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ 3 空态
|
|
|
|
|
+const emptyData = toOverviewWarningSettings(EMPTY_PAYLOAD)
|
|
|
|
|
+const emptyView = alertSettingsPanel(emptyData, ready(emptyData))
|
|
|
|
|
+assert.equal(emptyData.warning.state, 'EMPTY')
|
|
|
|
|
+assert.equal(handlingRateView(emptyData.warning.efficiency), '不适用', '空态已解除率=不适用(不是 0.0%)')
|
|
|
|
|
+assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').value, '不适用')
|
|
|
|
|
+assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').hint, '当前范围没有预警,已解除率不适用')
|
|
|
|
|
+assert.equal(emptyData.warning.workOrderStates, null, '空态下预警族工单口径仍是 null(族口径不随数据多少变)')
|
|
|
|
|
+assert.equal(sum(emptyData.warning.statusDistribution), 0, '空态分布全铺计数 0(真实计数,不是缺桶)')
|
|
|
|
|
+assert.equal(emptyData.alarm.efficiency.handlingReason, 'NO_ELIGIBLE_WORK_ORDERS', '报警族空态原因码透传')
|
|
|
|
|
+
|
|
|
|
|
+console.log('T42 warning-settings lifecycle contract: null-fidelity, four-distribution self-consistency, alarm work-order retention, empty-state not-applicable passed')
|