t44-distribution-ranking.mjs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. /**
  2. * 票 44 离线闸门:类型/类别分布块降序的**完备性普查** + 统计条视觉密度守卫。
  3. *
  4. * 票 37 的 t50 只钉住了「监测概览 · 设备类型分布」这一块。本票范围第 1 条要的是
  5. * 「**所有**类型分布统计块真的降序」——只测一块等于没测完备性(票 20 的教训:
  6. * 测试与实现犯同一个错时闸门照样全绿)。做法:
  7. *
  8. * A. 普查(行为层,不是正则):把**故意打乱次序**的后端载荷灌进真实适配层
  9. * (`toXxx` → `xxxPanel`),对每一块 bars/donut/stacked 断言精确次序。两类期望:
  10. * · RANK —— 排名类别(类型/行业/片区/区域):计数从多到少、并列保序;
  11. * · FIXED —— 固定语义序(状态/等级/生命周期/建成年限/工单 0–7/异常桶):
  12. * 与输入逐位相同(非目标第 2 条:不许强行改降序)。
  13. * 期望表是**穷举**的:表外的分布块判红、表内没出现的期望(幽灵条目)也判红——
  14. * 新增块必须显式归类,这就是「补齐遗漏块」的机制,不靠人记得去查。
  15. * B. 视觉密度(静态守卫):行内收紧、轨道发丝边、填充端帽/高光、辉光删除,
  16. * 且 `EllipsisText` / `panel-action` / `data-bar-*` 三条接缝未被视觉改动带走。
  17. *
  18. * 变异(守卫有牙):
  19. * M1 `sortByCountDesc` 改恒等 → 所有 RANK 块判红;
  20. * M2 把 FIXED 块也套上降序(如异常桶/等级)→ 对应 FIXED 判红;
  21. * M3 `.panel-bar` 的 gap 改回 `var(--space-2)` → B1 红;
  22. * M4 删 `.panel-bar__fill::after`(端帽/分割层)→ B2b 红;
  23. * M5 轨道内描边删除 → B2 红;
  24. * M6 行标签退回裸 `<span>` → B4 红。
  25. *
  26. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t44-distribution-ranking.mjs
  27. */
  28. import assert from 'node:assert/strict'
  29. import { readFileSync } from 'node:fs'
  30. import { barsBlock, sortByCountDesc } from '../../../src/modules/cockpit/domain/panelBlocks.js'
  31. import {
  32. archivePanel,
  33. emergencyPanel,
  34. managementPanel,
  35. operationPanel,
  36. toBaseArchives,
  37. toEmergencySupport,
  38. toOperatingInformation,
  39. } from '../../../src/modules/cockpit/domain/infrastructurePanels.js'
  40. import {
  41. monitoringDistributionPanel,
  42. monitoringOverviewPanel,
  43. operationOverviewPanel,
  44. toMonitoringDistribution,
  45. toMonitoringOverview,
  46. toMonitoringSituation,
  47. } from '../../../src/modules/cockpit/domain/monitoringPanels.js'
  48. import {
  49. alertSettingsPanel,
  50. infrastructureSummaryPanel,
  51. monitoringSummaryPanel,
  52. situationSummaryPanel,
  53. toOverviewInfrastructure,
  54. toOverviewMonitoring,
  55. toOverviewRegions,
  56. toOverviewWarningSettings,
  57. } from '../../../src/modules/cockpit/domain/overviewPanels.js'
  58. import {
  59. eventAnalysisPanel,
  60. historyAnalysisPanel,
  61. responseEfficiencyPanel,
  62. toEventAnalysis,
  63. toEventHistory,
  64. toHandlingEfficiency,
  65. } from '../../../src/modules/cockpit/domain/warningResponsePanels.js'
  66. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  67. const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
  68. const sv = (data) => section(SECTION_STATE.SUCCESS, { data })
  69. let passed = 0
  70. let failed = 0
  71. function check(name, condition, message) {
  72. if (condition) {
  73. passed += 1
  74. console.log(`PASS ${name}`)
  75. } else {
  76. failed += 1
  77. console.error(`FAIL ${name}: ${message}`)
  78. }
  79. }
  80. const isDesc = (counts) => counts.every((c, i) => i === 0 || counts[i - 1] >= c)
  81. /**
  82. * 普查一个面板视图:
  83. * · 每个分布块(bars/donut/stacked)的标题必须**恰好命中一条**期望(表外判红);
  84. * · 命中后比对精确次序(counts 与 keys 都断言);
  85. * · RANK 额外断言单调降序,FIXED 额外断言「不是碰巧也降序」(期望本身必须非单调,
  86. * 否则它证明不了没被重排——这是本组断言的牙)。
  87. * @param {string} name 面板名
  88. * @param {object} view 视图计划
  89. * @param {Array<{label: string, mode: 'RANK'|'FIXED', counts: number[], keys?: string[]}>} expect 期望表
  90. */
  91. /**
  92. * 把一块视图展开成待审的分布序列(票 38 新增 dimbars 后必需):
  93. * 普通块=一条;dimbars=每个维度一条(标题带维度名,两个维度各自进普查,一个都逃不掉)。
  94. * 不这样展开的话,新块类型会**绕过普查过滤器**——那正是本闸门要防的「漏归类」漏洞本身。
  95. */
  96. function seriesOf(block) {
  97. if (block.kind !== 'dimbars') return [{ label: block.label, rows: block.rows }]
  98. return (block.dimensions || []).map((dim) => ({ label: `${block.label} · ${dim.label}`, rows: dim.rows }))
  99. }
  100. function audit(name, view, expect) {
  101. const blocks = ((view && view.blocks) || []).filter((b) => ['bars', 'donut', 'stacked', 'dimbars'].includes(b.kind))
  102. const seen = new Set()
  103. const orphans = []
  104. for (const block of blocks) {
  105. for (const series of seriesOf(block)) {
  106. const hit = expect.filter((e) => e.label === series.label)
  107. if (hit.length !== 1) {
  108. orphans.push(`${series.label}(命中 ${hit.length} 条期望)`)
  109. continue
  110. }
  111. const e = hit[0]
  112. seen.add(e.label)
  113. const counts = (series.rows || []).map((r) => r.count)
  114. const keys = (series.rows || []).map((r) => r.key)
  115. let detail = `「${series.label}」次序 ${counts.join('、')}`
  116. if (e.keys) detail += `;键 ${keys.join('、')}`
  117. check(
  118. `A-${name}-${e.label}`,
  119. JSON.stringify(counts) === JSON.stringify(e.counts) && (!e.keys || JSON.stringify(keys) === JSON.stringify(e.keys)),
  120. `${detail}(期望 ${e.counts.join('、')}${e.keys ? `;键 ${e.keys.join('、')}` : ''})`,
  121. )
  122. if (e.mode === 'RANK') {
  123. check(`A-${name}-${e.label}-monotone`, isDesc(e.counts), `RANK 期望本身必须降序:${e.counts.join('、')}`)
  124. } else {
  125. check(`A-${name}-${e.label}-not-desc`, !isDesc(e.counts), `FIXED 期望必须**非单调**,否则证明不了没被强行降序:${e.counts.join('、')}`)
  126. }
  127. }
  128. }
  129. check(`A-${name}-no-unclassified-block`, orphans.length === 0, `存在未归类的分布块:${orphans.join(';')} —— 新增块必须在期望表里显式声明 RANK/FIXED`)
  130. const ghosts = expect.filter((e) => !seen.has(e.label)).map((e) => e.label)
  131. check(`A-${name}-no-ghost-expectations`, ghosts.length === 0, `期望表里有面板不存在的块(块被改名/删除,表在骗人):${ghosts.join('、')}`)
  132. }
  133. // ================================================================ A. 真实适配层 + 打乱载荷
  134. // ---- A1 运行监测 · 监测概览(设备类型分布:并列 5、5 必须保持传入次序)
  135. {
  136. const data = toMonitoringOverview({
  137. counts: { monitoringPointCount: 6, equipmentCount: 11 },
  138. status: { onlineCount: 8, offlineCount: 1, staleCount: 1, unknownOrMissingCount: 1, denominator: 11 },
  139. onlineRate: { value: '0.7273', numerator: 8, denominator: 11, state: 'AVAILABLE' },
  140. typeDistribution: [
  141. { deviceType: { code: 'SMALL', name: '少' }, industry: 'GAS', deviceCount: 1 },
  142. { deviceType: { code: 'TIE_A', name: '并列甲' }, industry: 'GAS', deviceCount: 5 },
  143. { deviceType: { code: 'TIE_B', name: '并列乙' }, industry: 'WATER_SUPPLY', deviceCount: 5 },
  144. { deviceType: { code: 'MID', name: '中' }, industry: 'DRAINAGE', deviceCount: 3 },
  145. ],
  146. mapFilter: {},
  147. excluded: {},
  148. freshness: {},
  149. })
  150. const view = monitoringOverviewPanel(data, sv(data))
  151. audit('monitoring-overview', view, [
  152. { label: '设备类型分布', mode: 'RANK', counts: [5, 5, 3, 1], keys: ['TIE_A', 'TIE_B', 'MID', 'SMALL'] },
  153. ])
  154. }
  155. // ---- A2 运行监测 · 监测分布(片区/行业/设备类型降序;异常桶固定语义序)
  156. {
  157. const data = toMonitoringDistribution({
  158. districtDistribution: [
  159. { district: 0, pointCount: 1, deviceCount: 1 },
  160. { district: 1, pointCount: 4, deviceCount: 4 },
  161. { district: 2, pointCount: 2, deviceCount: 2 },
  162. ],
  163. industryDistribution: [
  164. { industry: 'GAS', pointCount: 3, deviceCount: 3 },
  165. { industry: 'WATER_SUPPLY', pointCount: 6, deviceCount: 6 },
  166. { industry: 'DRAINAGE', pointCount: 2, deviceCount: 2 },
  167. ],
  168. deviceTypeDistribution: [
  169. { deviceType: { code: 'A', name: '型A' }, industry: 'GAS', pointCount: 1, deviceCount: 1 },
  170. { deviceType: { code: 'B', name: '型B' }, industry: 'GAS', pointCount: 5, deviceCount: 5 },
  171. { deviceType: { code: 'C', name: '型C' }, industry: 'GAS', pointCount: 3, deviceCount: 3 },
  172. ],
  173. pointStatusDistribution: [],
  174. monitoringPoints: [],
  175. unassociatedEquipment: [],
  176. pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0, hasMore: false },
  177. mapFilter: {},
  178. freshness: {},
  179. excluded: {},
  180. })
  181. const view = monitoringDistributionPanel(data, sv(data), {
  182. statusDistribution: { available: true, counts: { ALARM: 9, WARNING: 2, OFFLINE: 1, EMPTY: 4 } },
  183. selectedStatuses: [],
  184. })
  185. audit('monitoring-distribution', view, [
  186. { label: '覆盖情况 · 行业', mode: 'RANK', counts: [6, 3, 2] },
  187. { label: '覆盖情况 · 片区', mode: 'RANK', counts: [4, 2, 1] },
  188. { label: '覆盖情况 · 设备类型', mode: 'RANK', counts: [5, 3, 1] },
  189. // 异常桶=状态语义序(ALARM→WARNING→OFFLINE→EMPTY),不许被降序打乱。
  190. { label: '监测点异常统计(全局未筛)', mode: 'FIXED', counts: [9, 2, 1, 4] },
  191. ])
  192. }
  193. // ---- A3 运行监测 · 运行概况(指标五桶与状态分布都是字典固定序)
  194. {
  195. const data = toMonitoringSituation({
  196. // 故意乱序灌入:适配层按六态字典序重建,视图必须原样带出。
  197. metricStatusDistribution: [
  198. { status: 'OFFLINE', count: 8 },
  199. { status: 'WARNING', count: 7 },
  200. { status: 'NORMAL', count: 1 },
  201. { status: 'ALARM', count: 3 },
  202. { status: 'EXPIRED', count: 2 },
  203. { status: 'UNKNOWN', count: 0 },
  204. ],
  205. abnormalEquipment: [],
  206. warningTrend: [],
  207. alarmTrend: [],
  208. pagination: { total: 0 },
  209. freshness: {},
  210. excluded: {},
  211. })
  212. const view = operationOverviewPanel(data, sv(data))
  213. audit('monitoring-situation', view, [
  214. { label: '设备/指标异常统计', mode: 'FIXED', counts: [7, 3, 2, 8, 0] },
  215. { label: '异常态势 · 指标状态分布', mode: 'FIXED', counts: [1, 7, 3, 2, 8, 0] },
  216. ])
  217. }
  218. // ---- A4 总体概览(运行监测类型分布 + 基础设施行业构成/片区分布 + 态势总览无分布块)
  219. {
  220. const monitoring = toOverviewMonitoring({
  221. counts: { deviceCount: 11, monitoringPointCount: 6, monitoringFacilityCount: 3, infrastructureFacilityCount: 5 },
  222. status: { online: 8, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 11 },
  223. onlineRate: { value: '0.7273', onlineCount: 8, denominator: 11, state: 'AVAILABLE' },
  224. formalScope: { industries: ['GAS'] },
  225. metricFreshness: {},
  226. typeDistribution: [
  227. { industry: { code: 'GAS', label: '燃气' }, deviceType: { code: 'A', name: '型A' }, deviceCount: 1 },
  228. { industry: { code: 'GAS', label: '燃气' }, deviceType: { code: 'B', name: '型B' }, deviceCount: 6 },
  229. { industry: { code: 'WATER_SUPPLY', label: '供水' }, deviceType: { code: 'C', name: '型C' }, deviceCount: 4 },
  230. ],
  231. excluded: {},
  232. })
  233. const infra = toOverviewInfrastructure({
  234. totals: { facilityCount: 9, smartDeviceCount: 11 },
  235. industries: [
  236. { code: 'DRAINAGE', label: '排水', key: 'drainage', facilityCount: 1, smartDeviceCount: 2 },
  237. { code: 'GAS', label: '燃气', key: 'gas', facilityCount: 5, smartDeviceCount: 4 },
  238. { code: 'WATER_SUPPLY', label: '供水', key: 'water', facilityCount: 3, smartDeviceCount: 9 },
  239. ],
  240. excluded: {},
  241. })
  242. const regions = toOverviewRegions({
  243. totals: { facilityCount: 9, lengthKm: 12.4, scale: 'medium' },
  244. districtDistribution: [
  245. { district: 'UNASSIGNED', count: 1 },
  246. { district: 2, count: 4 },
  247. { district: 0, count: 3 },
  248. ],
  249. ageReference: {},
  250. excluded: {},
  251. })
  252. audit('overview-monitoring', monitoringSummaryPanel(monitoring, sv(monitoring)), [
  253. { label: '类型分布 · 智能设备', mode: 'RANK', counts: [6, 4, 1] },
  254. ])
  255. audit('overview-infrastructure', infrastructureSummaryPanel({ infrastructure: infra, regions }, sv(infra)), [
  256. { label: '行业构成 · 设施', mode: 'RANK', counts: [5, 3, 1] },
  257. { label: '行业构成 · 智能设备', mode: 'RANK', counts: [9, 4, 2] },
  258. { label: '片区分布', mode: 'RANK', counts: [4, 3, 1] },
  259. ])
  260. // 态势总览没有分布块(KPI+趋势);空期望表 = 出现任何未声明的分布块即红。
  261. audit('overview-situation', situationSummaryPanel({ infrastructure: infra, monitoring, warningSettings: null }, sv(null)), [])
  262. }
  263. // ---- A5 总体概览 · 预警设置(票 42 生命周期四块:状态/等级固定序,行业/片区降序)
  264. {
  265. const data = toOverviewWarningSettings({
  266. warning: {
  267. eventFamily: 'WARNING',
  268. state: 'READY',
  269. total: 31,
  270. handled: null,
  271. pending: null,
  272. cancelledCount: null,
  273. unlinkedEventCount: null,
  274. workOrderStates: null,
  275. handlingEfficiency: { value: '0.6129', state: 'AVAILABLE', numerator: 19, denominator: 31 },
  276. statusDistribution: [
  277. { code: 'UNHANDLED', label: '待处置', count: 12 },
  278. { code: 'HANDLED', label: '已解除', count: 19 },
  279. ],
  280. levelDistribution: [
  281. { code: '1', label: '1 级', count: 2 },
  282. { code: '2', label: '2 级', count: 9 },
  283. ],
  284. industryDistribution: [
  285. { code: 'GAS', label: '燃气', count: 4 },
  286. { code: 'DRAINAGE', label: '排水', count: 17 },
  287. { code: 'WATER_SUPPLY', label: '供水', count: 10 },
  288. ],
  289. districtDistribution: [
  290. { code: '0', label: '城南', count: 1 },
  291. { code: '1', label: '城北', count: 20 },
  292. ],
  293. trend: [],
  294. },
  295. alarm: {
  296. eventFamily: 'ALARM',
  297. state: 'READY',
  298. total: 66,
  299. handled: 40,
  300. pending: 20,
  301. cancelledCount: 3,
  302. unlinkedEventCount: 3,
  303. workOrderStates: { 0: 5, 1: 4, 2: 6, 3: 7, 4: 8, 5: 40, 6: 2, 7: 3 },
  304. handlingEfficiency: { value: '0.6061', state: 'AVAILABLE', numerator: 40, denominator: 66 },
  305. statusDistribution: [
  306. { code: 'UNHANDLED', label: '未处理', count: 26 },
  307. { code: 'HANDLED', label: '已处理', count: 40 },
  308. ],
  309. levelDistribution: [
  310. { code: '1', label: '1 级', count: 26 },
  311. { code: '2', label: '2 级', count: 40 },
  312. ],
  313. industryDistribution: [
  314. { code: 'GAS', label: '燃气', count: 26 },
  315. { code: 'DRAINAGE', label: '排水', count: 40 },
  316. ],
  317. districtDistribution: [
  318. { code: '0', label: '城南', count: 6 },
  319. { code: '1', label: '城北', count: 60 },
  320. ],
  321. trend: [],
  322. },
  323. freshness: {},
  324. range: { type: 'LAST_7_DAYS' },
  325. })
  326. const view = alertSettingsPanel(data, sv(data))
  327. audit('alert-settings', view, [
  328. { label: '预警 · 生命周期状态', mode: 'FIXED', counts: [12, 19] },
  329. { label: '预警 · 等级分布', mode: 'FIXED', counts: [2, 9] },
  330. { label: '预警 · 行业分布', mode: 'RANK', counts: [17, 10, 4] },
  331. { label: '预警 · 片区分布', mode: 'RANK', counts: [20, 1] },
  332. { label: '报警 · 工单状态分布', mode: 'FIXED', counts: [5, 4, 6, 7, 8, 40, 2, 3] },
  333. { label: '报警 · 生命周期状态', mode: 'FIXED', counts: [26, 40] },
  334. { label: '报警 · 等级分布', mode: 'FIXED', counts: [26, 40] },
  335. { label: '报警 · 行业分布', mode: 'RANK', counts: [40, 26] },
  336. { label: '报警 · 片区分布', mode: 'RANK', counts: [60, 6] },
  337. ])
  338. }
  339. // ---- A6 预警处置 · 预警分析(类型/行业/片区降序;等级是语义阶梯固定序)
  340. {
  341. const data = toEventAnalysis({
  342. dimensions: [
  343. { dimensionCode: 'EVENT_TYPE', buckets: [
  344. { bucketCode: 'T1', bucketLabel: '类型甲', count: 2 },
  345. { bucketCode: 'T2', bucketLabel: '类型乙', count: 8 },
  346. { bucketCode: 'T3', bucketLabel: '类型丙', count: 5 },
  347. ] },
  348. { dimensionCode: 'LEVEL', buckets: [
  349. { bucketCode: '1', bucketLabel: '1 级', count: 1 },
  350. { bucketCode: '2', bucketLabel: '2 级', count: 9 },
  351. ] },
  352. { dimensionCode: 'INDUSTRY', buckets: [
  353. { bucketCode: 'GAS', bucketLabel: '燃气', count: 3 },
  354. { bucketCode: 'DRAINAGE', bucketLabel: '排水', count: 11 },
  355. ] },
  356. { dimensionCode: 'DISTRICT', buckets: [
  357. { bucketCode: '0', bucketLabel: '城南', count: 7 },
  358. { bucketCode: '1', bucketLabel: '城北', count: 4 },
  359. ] },
  360. ],
  361. highlights: [],
  362. })
  363. audit('event-analysis', eventAnalysisPanel(data, sv(data)), [
  364. { label: '按事件类型', mode: 'RANK', counts: [8, 5, 2] },
  365. { label: '按行业分布', mode: 'RANK', counts: [11, 3] },
  366. { label: '按事件等级', mode: 'FIXED', counts: [1, 9] },
  367. { label: '按片区分布', mode: 'RANK', counts: [7, 4] },
  368. ])
  369. }
  370. // ---- A7 基础设施 · 基础档案(行业/片区降序;建成年限是时间轴分桶固定序)
  371. {
  372. const data = toBaseArchives({
  373. totals: { facilityCount: 9, lengthKm: 12.4, scale: 'medium' },
  374. industryDistribution: [
  375. { code: 'MANHOLE', count: 1 },
  376. { code: 'GAS', count: 6 },
  377. { code: 'DRAINAGE', count: 3 },
  378. ],
  379. districtDistribution: [
  380. { district: 2, count: 2 },
  381. { district: 0, count: 5 },
  382. ],
  383. ageDistribution: [
  384. { code: '20_YEARS_OR_OVER', count: 9 },
  385. { code: 'UNDER_5_YEARS', count: 1 },
  386. { code: '10_TO_20_YEARS', count: 4 },
  387. { code: '5_TO_10_YEARS', count: 2 },
  388. ],
  389. ageReference: {},
  390. excluded: {},
  391. })
  392. audit('base-archives', archivePanel(data, sv(data)), [
  393. { label: '行业分类', mode: 'RANK', counts: [6, 3, 1] },
  394. { label: '片区分布', mode: 'RANK', counts: [5, 2] },
  395. { label: '档案摘要 · 建成年限', mode: 'FIXED', counts: [9, 1, 4, 2] },
  396. ])
  397. }
  398. // ---- A8 基础设施 · 应急保障(区域分布降序)
  399. {
  400. const data = toEmergencySupport({
  401. totals: { hazardSourceCount: 3, protectionTargetCount: 2, emergencyResourceCount: 1, totalObjectCount: 6 },
  402. sections: {
  403. hazardSources: { state: 'EMPTY', items: [] },
  404. protectionTargets: { state: 'EMPTY', items: [] },
  405. emergencyResources: { state: 'EMPTY', items: [] },
  406. },
  407. industryDistribution: [{ industry: 'GAS', count: 1 }],
  408. areaDistribution: [
  409. { area: '城东', count: 2 },
  410. { area: '城南', count: 9 },
  411. { area: '城北', count: 5 },
  412. ],
  413. eventSummary: {},
  414. })
  415. audit('emergency', emergencyPanel(data, sv(data)), [
  416. { label: '区域分布', mode: 'RANK', counts: [9, 5, 2] },
  417. ])
  418. }
  419. // ---- A9 预警处置 · 预警效率(工单 0–7 全铺字典序,计数最大不得置顶)
  420. {
  421. const data = toHandlingEfficiency({
  422. handlingEfficiency: { value: '0.5', state: 'AVAILABLE', numerator: 3, denominator: 6 },
  423. workOrderStateCounts: [
  424. { stateCode: '1', count: 9 },
  425. { stateCode: '0', count: 1 },
  426. { stateCode: '5', count: 3 },
  427. ],
  428. trend: [],
  429. })
  430. audit('response-efficiency', responseEfficiencyPanel(data, sv(data)), [
  431. { label: '工单状态分布', mode: 'FIXED', counts: [1, 9, 0, 0, 0, 3, 0, 0] },
  432. ])
  433. }
  434. // ---- A10 无分布块的面板也进普查:期望表为空,长出未声明的分布块即红(完备性反向锁定)
  435. {
  436. const history = historyAnalysisPanel(toEventHistory({ data: [], trend: [] }), sv(null))
  437. audit('history-analysis', history, [])
  438. const management = managementPanel(
  439. { facilityCount: 9, equipmentCount: 11, districtIndustry: [
  440. { district: 0, industry: 'GAS', facilityCount: 2, equipmentCount: 2 },
  441. { district: 1, industry: 'GAS', facilityCount: 5, equipmentCount: 6 },
  442. { district: 1, industry: 'WATER_SUPPLY', facilityCount: 2, equipmentCount: 3 },
  443. ] },
  444. sv(null),
  445. )
  446. // 票 38:管理信息新增 dimbars(片区 × 行业可切换柱状图),两个维度都是排名类别 → RANK。
  447. // 标题带维度名(seriesOf 展开),一维漏归类即孤儿。
  448. audit('management', management, [
  449. { label: '片区 × 行业 · 按片区', mode: 'RANK', counts: [7, 2], keys: ['1', '0'] },
  450. { label: '片区 × 行业 · 按行业', mode: 'RANK', counts: [7, 2], keys: ['GAS', 'WATER_SUPPLY'] },
  451. ])
  452. // 运行信息:KPI + 指标清单(票 38 才把它图表化);现在长出分布块就是未声明的改动。
  453. const operation = operationPanel(
  454. toOperatingInformation({ industries: [{ code: 'GAS', label: '燃气', metrics: [] }] }),
  455. sv(null),
  456. )
  457. audit('operation', operation, [])
  458. }
  459. // ================================================================ B. 并列稳定性 + 视觉密度守卫
  460. {
  461. // B1 并列保序由代码结构保证(票 37 条文在本票复验):全并列原样带出、与码值无关。
  462. const rows = [{ code: 'A', count: 5 }, { code: 'B', count: 5 }, { code: 'C', count: 5 }]
  463. assert.deepEqual(sortByCountDesc(rows, (r) => r.count).map((r) => r.code), ['A', 'B', 'C'], '全并列必须原样带出')
  464. const rev = [{ code: 'C', count: 5 }, { code: 'B', count: 5 }, { code: 'A', count: 5 }]
  465. assert.deepEqual(sortByCountDesc(rev, (r) => r.count).map((r) => r.code), ['C', 'B', 'A'], '并列次序跟传入,不跟码值')
  466. check('B1-tie-stability-by-structure', true, '并列保序')
  467. // B2 并列行条长相同(视觉可比较);全 0 不产生 NaN。
  468. const block = barsBlock('x', [{ label: '甲', count: 5 }, { label: '乙', count: 5 }, { label: '丙', count: 1 }])
  469. check('B2-tie-ratio-equal', block.rows[0].ratio === block.rows[1].ratio && block.rows[0].ratio === 1, '并列计数得到相同条长,最大项为 1')
  470. const zero = barsBlock('x', [{ label: '甲', count: 0 }])
  471. check('B2b-all-zero-no-nan', Number.isFinite(zero.rows[0].ratio) && zero.rows[0].ratio === 0, '全 0 时条长 0,不得 NaN')
  472. }
  473. {
  474. const panelVue = read('components/PanelBlocks.vue')
  475. /** 取某选择器的声明块(简单括号配对;找不到回空串,断言会因此判红而不是漏判)。 */
  476. const ruleOf = (selector) => {
  477. const at = panelVue.indexOf(`\n${selector} {`)
  478. if (at < 0) return ''
  479. const open = panelVue.indexOf('{', at)
  480. let depth = 0
  481. for (let i = open; i < panelVue.length; i += 1) {
  482. if (panelVue[i] === '{') depth += 1
  483. else if (panelVue[i] === '}') {
  484. depth -= 1
  485. if (depth === 0) return panelVue.slice(open + 1, i)
  486. }
  487. }
  488. return ''
  489. }
  490. // B3 每行文字与统计条更紧密:列间距 8u(--space-2) → 4u,且不再引用宽间距 token。
  491. const barRule = ruleOf('.panel-bar')
  492. check('B3-bar-row-tightened', /gap:\s*calc\(4 \* var\(--u/.test(barRule) && !/gap:\s*var\(--space-2/.test(barRule),
  493. `条形行的列间距必须收到 4u(文字与条贴合),当前:${barRule.trim() || '(找不到 .panel-bar 规则)'}`)
  494. // B4 条段之间有明确分割:轨道内发丝描边(起止容器)+ 填充右端头帽与顶部高光(装饰层不挡悬停)。
  495. const trackRule = ruleOf('.panel-bar__track')
  496. const fillRule = ruleOf('.panel-bar__fill')
  497. const capRule = ruleOf('.panel-bar__fill::after')
  498. check('B4-track-hairline-boundary', /box-shadow:\s*inset 0 0 0 1px/.test(trackRule), '轨道必须有 1u 内描边,条段才看得出容器边界')
  499. check('B4b-fill-cap-and-layer', /inset -1px 0 0/.test(capRule) && /linear-gradient\(180deg/.test(capRule) && /pointer-events:\s*none/.test(capRule),
  500. '填充需要右端头帽 + 顶部高光装饰层(轻量层次),且不挡悬停')
  501. check('B4c-fill-is-positioning-parent', /position:\s*relative/.test(fillRule), '装饰层贴填充本体,填充需为定位父级')
  502. // B5 越界辉光删除:旧 `0 0 8px` 渗过 2u 行距糊进相邻条,正是「条段无分割」的直接成因。
  503. check('B5-no-bleeding-glow', !/box-shadow:\s*0 0 8px/.test(fillRule), '条形填充不得再带 8px 外辉光(跨行渗色 = 分割不清)')
  504. // B6 读数密度与可读性不牺牲:读数列仍右对齐等宽;行距维持 2u(收紧的是行内)。
  505. const countRule = ruleOf('.panel-bar__count')
  506. const barsRule = ruleOf('.panel-bars')
  507. check('B6-reading-density-kept', /text-align:\s*right/.test(countRule) && /font-family:\s*var\(--num/.test(countRule), '读数列保持右对齐等宽数字')
  508. check('B6b-row-pitch-unchanged', /gap:\s*calc\(2 \* var\(--u/.test(barsRule), '行间距维持 2u(不把行堆挤)')
  509. check('B6c-track-height-6u', /height:\s*calc\(6 \* var\(--u/.test(trackRule), '轨道 5u→6u 承载描边与端帽,但不越过读数行高')
  510. // B7 三条接缝原样(票 37 收口条件:EllipsisText / panel-action / data-bar-* 不变)。
  511. // 票 38:dimbars 复用了同一条 `panel-bar__label` 省略接缝后,该行不再唯一(bars + dimbars 两处)——
  512. // 存在性断言在重复行上没有牙(退回一处裸 span,另一处照样命中)。改断「每一处标签行都走 EllipsisText」。
  513. const labelLines = (panelVue.match(/class="panel-bar__label"/g) || []).length
  514. const ellipsisLabelLines = (panelVue.match(/<EllipsisText class="panel-bar__label"/g) || []).length
  515. check('B7-ellipsis-seam-intact', labelLines >= 2 && labelLines === ellipsisLabelLines, `条形行标签必须**全部**走共用省略方法(标签行 ${labelLines} 处 / EllipsisText ${ellipsisLabelLines} 处)`)
  516. check('B7b-panel-action-seam-intact', /emit\('panel-action', \{ kind: block\.action, key: row\.actionKey \}\)/.test(panelVue), 'panel-action 仍只转发 actionKey(视觉改动不碰语义接缝)')
  517. check('B7c-assertion-surface-intact', /data-bar="row\.key"/.test(panelVue) && /data-bar-count/.test(panelVue) && /data-bar-selected/.test(panelVue) && /data-bar-tone/.test(panelVue), '走查断言面 data-bar-* 原样保留')
  518. // B8 交互与既有横条图一致:悬停增亮保留;选中态不改条长口径。
  519. check('B8-hover-feedback-kept', /\.panel-bar:hover \.panel-bar__fill\s*\{[^}]*filter:\s*brightness/.test(panelVue), '条形悬停增亮必须保留')
  520. const activeRule = ruleOf('.panel-bar--button.is-active')
  521. check('B8b-selected-does-not-change-bar', activeRule.includes('background') && !/width/.test(activeRule), '选中态只给底色/边线,不得改条宽或计数呈现')
  522. // B9 省略声明仍只有一份(票 37 的 A4 不变量,视觉改动不许顺手加第二份)。
  523. check('B9-single-ellipsis-declaration', !/text-overflow/.test(panelVue), 'PanelBlocks.vue 不得重新长出第二份 text-overflow')
  524. }
  525. console.log('')
  526. console.log(`T44 distribution ranking & visual density: ${failed ? `${failed} FAILED` : `${passed} passed`}`)
  527. if (failed) process.exit(1)