t44-distribution-ranking.mjs 26 KB

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