t54-monitoring-overview-grid.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * 票 54 离线闸门:监测概览数据时间对齐 + 四统计卡 2×2。
  3. *
  4. * 票面四条验收逐条对上(不启动 build/dev/browser,按 AGENTS.md):
  5. * G1 数据时间语义:短读法上屏、完整 ISO 进 title、缺值仍是占位符(绝不伪造时间)、
  6. * 认不出的时间串原样带出(不丢信息、不编造);两处消费点(监测概览 / 运行概况)
  7. * 必须走**同一个** helper——分头手写正是「早晚不一致」的来源。
  8. * G2 布局:`layout` 由 domain 声明、渲染层只认这个通用字段(票 38 纪律:不按 key 特判);
  9. * 常规宽度固定 2×2;窄态降级按**容器**判(面板栏宽 280–460px 由 `--column-w` 固定,
  10. * 视口 media query 在大屏永不触发=写一条不会生效的规则);两档都 `minmax(0, ·)` 不横溢。
  11. * G3 视觉层级统一:时间读数与其余 KPI 同档 `--fs-kpi`,且专门的降档规则**整条删除**
  12. * (规格 03「无消费点不提交」)。
  13. * G4 不回归:四项 key/label/unit 顺序、在线率 hint、未声明 layout 的面板不受影响。
  14. *
  15. * 变异闸:node .scratch/cockpit-real-data-wayfinding/verify/t54-mutations.mjs
  16. * node .scratch/cockpit-real-data-wayfinding/verify/t54-monitoring-overview-grid.mjs
  17. */
  18. import assert from 'node:assert/strict'
  19. import { readFileSync } from 'node:fs'
  20. import {
  21. monitoringOverviewPanel,
  22. operationOverviewPanel,
  23. toMonitoringOverview,
  24. toMonitoringSituation,
  25. } from '../../../src/modules/cockpit/domain/monitoringPanels.js'
  26. import { kpiBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js'
  27. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  28. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  29. const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
  30. const sv = (data) => section(SECTION_STATE.SUCCESS, { data })
  31. let passed = 0
  32. let failed = 0
  33. function check(name, condition, message) {
  34. if (condition) {
  35. passed += 1
  36. console.log(`PASS ${name}`)
  37. } else {
  38. failed += 1
  39. console.error(`FAIL ${name}: ${message}`)
  40. }
  41. }
  42. const ISO = '2026-10-12T15:15:47+08:00'
  43. // 真实适配层灌进去,不手拼领域对象(票 20 的教训:形状骗一次就白测一次)。
  44. const overviewData = toMonitoringOverview({
  45. counts: { monitoringPointCount: 7, equipmentCount: 9 },
  46. status: { onlineCount: 3, offlineCount: 6, staleCount: 0, unknownOrMissingCount: 0, denominator: 9 },
  47. onlineRate: { value: '0.3333', numerator: 3, denominator: 9, state: 'AVAILABLE' },
  48. createdAt: ISO,
  49. typeDistribution: [{ deviceType: { code: '12', name: '压力计' }, industry: 'WATER_SUPPLY', deviceCount: 1 }],
  50. mapFilter: {},
  51. excluded: {},
  52. freshness: {},
  53. })
  54. const overview = monitoringOverviewPanel(overviewData, sv(overviewData))
  55. const kpi = overview.blocks.find((b) => b.kind === 'kpi')
  56. // ================================================================ G1 数据时间语义
  57. {
  58. const time = kpi.items.find((i) => i.key === 'createdAt')
  59. check('G1a-shown-reading-is-short', time.value === '10-12 15:15',
  60. `屏上读数必须是短读法(全串在 2×2 半格里必然省略成「${ISO.slice(0, 14)}…」),实得 ${JSON.stringify(time.value)}`)
  61. check('G1b-title-carries-full-iso', time.full === ISO,
  62. `完整原文必须由 full 带出、交给共用省略方法做 title(票 37 接缝),实得 ${JSON.stringify(time.full)}`)
  63. check('G1c-short-is-not-the-only-copy', time.full !== time.value,
  64. '显示与原文是两个字段:只有一个字段就是「要么截断、要么挤爆」的老问题')
  65. // 缺值纪律:占位符照旧,绝不喂给短读法编出时间。
  66. const empty = monitoringOverviewPanel(toMonitoringOverview({
  67. counts: {}, status: {}, onlineRate: { state: 'EMPTY' }, createdAt: null, mapFilter: {}, excluded: {}, freshness: {},
  68. }), sv(null))
  69. const emptyTime = empty.blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
  70. check('G1d-missing-time-still-placeholder', emptyTime.value === DETAIL_PLACEHOLDER && emptyTime.full === DETAIL_PLACEHOLDER,
  71. `缺 createdAt 仍是占位符(value/full 同为「${DETAIL_PLACEHOLDER}」),实得 ${JSON.stringify([emptyTime.value, emptyTime.full])}`)
  72. // 认不出的时间串原样带出(票 13 同一条纪律:不丢信息、不编造)。
  73. const odd = monitoringOverviewPanel(toMonitoringOverview({
  74. counts: {}, status: {}, onlineRate: { state: 'EMPTY' }, createdAt: 'record', mapFilter: {}, excluded: {}, freshness: {},
  75. }), sv(null))
  76. const oddTime = odd.blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
  77. check('G1e-unparsable-time-verbatim', oddTime.value === 'record' && oddTime.full === 'record',
  78. `解析不了的时间原样带出,实得 ${JSON.stringify([oddTime.value, oddTime.full])}`)
  79. // 运行概况(同为四卡 + 数据时间)必须走同一条链,不是第二份实现。
  80. const situation = toMonitoringSituation({ createdAt: ISO, metricStatusDistribution: [], abnormalEquipment: [] })
  81. const opTime = operationOverviewPanel(situation, sv(situation)).blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
  82. check('G1f-operation-overview-same-seam', opTime.value === '10-12 15:15' && opTime.full === ISO,
  83. `运行概况的数据时间与监测概览同源同形,实得 ${JSON.stringify([opTime.value, opTime.full])}`)
  84. }
  85. // ================================================================ G2 布局:domain 声明 + 容器降级
  86. {
  87. check('G2a-overview-declares-two-by-two', kpi.layout === 'two-by-two',
  88. `监测概览必须在 domain 声明 2×2(票 38 纪律:布局意图归 domain),实得 ${JSON.stringify(kpi.layout)}`)
  89. // 未声明 layout 的调用点不受影响(共享 kpiBlock 的默认值不许改别的 12 处面板)。
  90. const plain = kpiBlock([{ key: 'x', label: '甲', value: '1' }])
  91. check('G2b-default-layout-is-empty', plain.layout === '', `kpiBlock 默认 layout 必须是空串,实得 ${JSON.stringify(plain.layout)}`)
  92. const panelVue = read('components/PanelBlocks.vue')
  93. const tpl = panelVue.slice(0, panelVue.indexOf('</template>'))
  94. check('G2c-renderer-reads-layout-not-key', /'panel-kpis--two-by-two':\s*block\.layout === 'two-by-two'/.test(tpl),
  95. '渲染层必须按 block.layout 加类(不认识任何业务 key)')
  96. check('G2d-no-key-special-case-in-template', !/item\.key === 'createdAt'/.test(tpl),
  97. '票 38 立的纪律:渲染层不按 item.key 特判布局/字号(那是第二份布局语义)')
  98. const css = panelVue.slice(panelVue.indexOf('<style'))
  99. check('G2e-two-columns-fixed-not-autofit', /\.panel-kpis--two-by-two\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(css),
  100. '2×2 必须是固定两列且列宽 minmax(0,·)(auto 列在窄格里会横向溢出)')
  101. check('G2f-narrow-fallback-is-container-query', /@container\s+panel-kpi-grid/.test(css) && /container-type:\s*inline-size/.test(css),
  102. '窄态降级必须按容器判:面板栏宽固定 280–460px,视口 media query 在大屏永不触发')
  103. check('G2g-no-viewport-media-query-for-kpi', !/@media[^{]*\{[^}]*panel-kpis/.test(css),
  104. 'KPI 降级不许退回视口 media query(那是一条永远不会生效的规则,等于没做验收第 2 条)')
  105. check('G2h-fallback-targets-cards', /@container\s+panel-kpi-grid[^{]*\{[^}]*\.panel-kpis--two-by-two\s+\.panel-kpi\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/.test(css),
  106. '容器查不了自身尺寸(CSS containment 规则),降级必须写在子卡上横贯两列')
  107. }
  108. // ================================================================ G3 视觉层级统一
  109. {
  110. const css = read('components/PanelBlocks.vue').slice(read('components/PanelBlocks.vue').indexOf('<style'))
  111. check('G3a-time-value-rule-removed', !/\.panel-kpi__value--time\s*\{/.test(css),
  112. '降档规则必须整条删除(规格 03「无消费点不提交」,留着就是死 CSS)')
  113. check('G3b-kpi-values-share-one-size', /\.panel-kpi__value\s*\{[^}]*font-size:\s*var\(--fs-kpi/.test(css),
  114. '所有 KPI 数值(含数据时间)共用同一条 --fs-kpi 规则 —— 同一视觉层级由「只有一档」保证')
  115. check('G3c-no-time-class-binding-left', !/panel-kpi__value--time/.test(read('components/PanelBlocks.vue')),
  116. '时间类名在模板与样式里都不许残留(半拆的改动最容易假绿)')
  117. }
  118. // ================================================================ G4 不回归
  119. {
  120. check('G4a-four-items-order-and-units', kpi.items.length === 4
  121. && kpi.items.map((i) => i.key).join(',') === 'monitoringPointCount,equipmentCount,onlineRate,createdAt'
  122. && kpi.items[0].unit === '个' && kpi.items[1].unit === '台',
  123. `四项顺序/单位是票面既有契约,实得 ${JSON.stringify(kpi.items.map((i) => [i.key, i.unit]))}`)
  124. check('G4b-online-rate-hint-intact', (kpi.items.find((i) => i.key === 'onlineRate').hint || '').includes('在线 3 / 设备 9'),
  125. `分母摊在在线率 hint 上(票 39 定稿),实得 ${JSON.stringify(kpi.items.find((i) => i.key === 'onlineRate').hint)}`)
  126. // 完整原文进 title:kpiReading 必须优先取 full,且没有 full 的读数行为不变。
  127. const vue = read('components/PanelBlocks.vue')
  128. check('G4c-kpireading-prefers-full', /item\.full \|\| item\.value/.test(vue), 'kpiReading 必须优先取 domain 的 full(title 才是完整原文)')
  129. check('G4d-unit-still-dropped-on-placeholder', /item\.unit && !isPlaceholder\(item\.value\)/.test(vue),
  130. '占位符不画单位这条既有纪律不许动(否则会出「— 台」半截读数)')
  131. check('G4e-chart-tip-anchor-untouched', /\.panel-blocks\s*\{[^}]*position:\s*relative/.test(vue),
  132. '.chart-tip 的定位基准仍是 .panel-blocks 根:container-type 只加在 KPI 网格上,没有换掉祖先')
  133. // containment 有副作用(`contain: layout` 让元素成为绝对定位后代的包含块),
  134. // 所以它必须**只**落在本变体上,而且绝不能落在 .panel-blocks 根上(那是 .chart-tip 的基准)。
  135. const cssOnly = vue.slice(vue.indexOf('<style'))
  136. const rootRule = /\.panel-blocks\s*\{[^}]*\}/.exec(cssOnly)
  137. check('G4f-container-type-not-on-root', Boolean(rootRule) && !/container-type/.test(rootRule[0]),
  138. '.panel-blocks 根规则不许加 container-type(会把 .chart-tip 的定位基准从 relative 变成带 layout 的容器)')
  139. const containmentRules = (cssOnly.match(/container-type:/g) || []).length
  140. check('G4g-container-type-declared-once', containmentRules === 1,
  141. `container-type 只允许一处(本票的 2×2 变体),实得 ${containmentRules} 处`)
  142. }
  143. console.log(`\nT54 monitoring overview grid: ${passed} passed, ${failed} failed`)
  144. if (failed) process.exitCode = 1