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