t24-pipe-layer.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * 票 24 离线回归:管线/设施几何图层(不联网)。
  3. *
  4. * 覆盖三件事:
  5. * 1. **契约**:后端 GeoJSON 形状 → 领域图层的转换([票 30](../issues/30-30.md) 交付物 1);
  6. * 2. **程序化构件分类**:样条管道(多段路径)/ 角管道(弯头)/ 阀门 / 支管管道(三通),
  7. * 以及未建模类型(调压等)**如实保留原文**而不是猜成已知类别;
  8. * 3. **坐标纪律**:未校验 / 零坐标 / 缺经度 一律**不得上图**,且必须带中文原因进「无地图位置」,
  9. * 既不被丢掉,也不被"挪一下"救回来。
  10. *
  11. * mock 样例在本脚本里现搭,不依赖 `mocks/contractMocks.js` 的注册时机,
  12. * 但形状必须与它一致(那才是浏览器里真正会吃到的载荷)。
  13. */
  14. import assert from 'node:assert/strict'
  15. import { toPipeNetwork, filterPipeNetwork, pipeWidth, PIPE_COMPONENT } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
  16. const valid = { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }
  17. /** 真实契约的 Feature 夹具:标准 GeoJSON,**没有 `kind` 字段**(按 geometry.type 判别)。 */
  18. const feature = (overrides) => ({
  19. type: 'Feature',
  20. id: 'pipe_network_base:P-1',
  21. geometry: { type: 'LineString', coordinates: [[110.39, 28.44], [110.40, 28.45]] },
  22. properties: { source: 'pipe_network_base', sourceId: 'P-1', code: 'P-1', industry: 'GAS', typeCode: 'PIPE', coordinateValidation: valid },
  23. ...overrides,
  24. })
  25. const component = (overrides) => ({
  26. type: 'Feature',
  27. id: 'gas_pipe_point:C-1',
  28. geometry: { type: 'Point', coordinates: [110.395, 28.445] },
  29. properties: { source: 'gas_pipe_point', sourceId: 'C-1', code: 'C-1', pointType: 'valve', networkId: 'P-1', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: valid },
  30. ...overrides,
  31. })
  32. let failed = 0
  33. function check(name, action) {
  34. try {
  35. action()
  36. console.log(`PASS ${name}`)
  37. } catch (error) {
  38. failed += 1
  39. console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`)
  40. }
  41. }
  42. const payload = {
  43. type: 'FeatureCollection',
  44. // coverage 是三态行:同一行业可有管线/构件两行
  45. coverage: [
  46. { industry: 'GAS', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 2, geometryCount: 2, renderableCount: 2 },
  47. { industry: 'WATER_SUPPLY', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 1, geometryCount: 1, renderableCount: 1 },
  48. { industry: 'GAS', source: 'gas_pipe_point', geometryType: 'Point', state: 'AVAILABLE', reason: null, recordCount: 4, geometryCount: 4, renderableCount: 4 },
  49. { industry: 'MANHOLE', source: null, geometryType: 'LineString', state: 'UNAVAILABLE', reason: 'NO_PIPELINE_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 },
  50. ],
  51. features: [
  52. feature({}),
  53. // 多段路径 = 样条管道的输入(真实库多为两点,但契约必须支持多段)
  54. feature({ geometry: { type: 'LineString', coordinates: [[110.39, 28.44], [110.395, 28.445], [110.40, 28.44]] }, properties: { source: 'pipe_network_base', sourceId: 'P-2', code: 'P-2', industry: 'GAS', typeCode: 'PIPE', diameter: 400, coordinateValidation: valid } }),
  55. feature({ properties: { source: 'pipe_network_base', sourceId: 'P-3', code: 'P-3', industry: 'WATER_SUPPLY', typeCode: 'PIPE', coordinateValidation: valid } }),
  56. component({}),
  57. component({ geometry: { type: 'Point', coordinates: [110.40, 28.44] }, properties: { source: 'gas_pipe_point', sourceId: 'C-2', code: 'C-2', pointType: 'elbow', networkId: 'P-1', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: valid } }),
  58. component({ geometry: { type: 'Point', coordinates: [110.39, 28.44] }, properties: { source: 'gas_pipe_point', sourceId: 'C-3', code: 'C-3', pointType: 'tee', networkId: 'P-2', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: valid } }),
  59. component({ geometry: { type: 'Point', coordinates: [110.41, 28.46] }, properties: { source: 'gas_pipe_point', sourceId: 'C-4', code: 'C-4', pointType: 'regulator', networkId: 'P-1', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: valid } }),
  60. // 中文词表也要认(数据底座注释里是中文,真实端点实测是英文,两侧都不能漏)
  61. component({ geometry: { type: 'Point', coordinates: [110.42, 28.47] }, properties: { source: 'gas_pipe_point', sourceId: 'C-5', code: 'C-5', pointType: '阀门', networkId: 'P-1', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: valid } }),
  62. ],
  63. }
  64. const network = toPipeNetwork(payload)
  65. check('B1 管段与构件分开归类,不混为一类对象', () => {
  66. assert.equal(network.pipes.length, 3)
  67. assert.equal(network.components.length, 5)
  68. assert.ok(network.pipes.every((row) => row.kind === undefined), '管段不带构件类别')
  69. })
  70. check('B2 多段路径原样保留(样条管道的输入不被压成两点)', () => {
  71. const spline = network.pipes.find((row) => row.code === 'P-2')
  72. assert.equal(spline.positions.length, 3)
  73. })
  74. check('B3 四类构件按后端真实属性分类(英文 pointType,实测形状)', () => {
  75. const byId = Object.fromEntries(network.components.map((row) => [row.code, row.kind]))
  76. assert.equal(byId['C-1'], PIPE_COMPONENT.VALVE, 'valve → 阀门')
  77. assert.equal(byId['C-2'], PIPE_COMPONENT.ELBOW, 'elbow → 角管道')
  78. assert.equal(byId['C-3'], PIPE_COMPONENT.BRANCH, 'tee → 支管管道')
  79. assert.equal(byId['C-4'], PIPE_COMPONENT.OTHER, 'regulator 未建模 → other')
  80. assert.equal(byId['C-5'], PIPE_COMPONENT.VALVE, '中文「阀门」也要认')
  81. })
  82. check('B4 未建模的构件类型保留后端原文,不被猜成已知类别', () => {
  83. const regulator = network.components.find((row) => row.code === 'C-4')
  84. assert.equal(regulator.label, 'regulator')
  85. })
  86. check('B5 构件记录它属于哪条管线(不做拓扑推断)', () => {
  87. const valve = network.components.find((row) => row.code === 'C-1')
  88. assert.equal(valve.networkId, 'P-1')
  89. })
  90. check('B6 行业覆盖按后端三态行转述,缺口如实记录', () => {
  91. assert.equal(network.coverage.GAS.geometryAvailable, true)
  92. assert.equal(network.coverage.GAS.pipeCount, 2, '管线段取 LineString 行')
  93. assert.equal(network.coverage.GAS.componentCount, 4, '构件段取 Point 行')
  94. assert.equal(network.coverage.MANHOLE.geometryAvailable, false, '窨井只有 UNAVAILABLE 行 → 如实说无')
  95. assert.ok(network.coverage.MANHOLE.gaps.some((gap) => gap.reason === 'NO_PIPELINE_SOURCE'), '缺口原因要留着,界面才说得清为什么')
  96. assert.equal(network.coverage.DRAINAGE.geometryAvailable, false, '后端没点名排水 → 不假装有')
  97. assert.equal(network.coverage.DRAINAGE.pipeCount, 0)
  98. })
  99. check('C1 未校验坐标不得上图,且带中文原因', () => {
  100. const blocked = toPipeNetwork({ features: [feature({ properties: { sourceId: 'X-1', code: 'X-1', industry: 'GAS', coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false } } })] })
  101. assert.equal(blocked.pipes.length, 0)
  102. assert.equal(blocked.blocked.length, 1)
  103. assert.equal(blocked.blocked[0].reasonText, '坐标未经校验')
  104. })
  105. check('C2 零坐标不得上图', () => {
  106. const blocked = toPipeNetwork({ features: [feature({ properties: { sourceId: 'X-2', code: 'X-2', industry: 'GAS', coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false } } })] })
  107. assert.equal(blocked.pipes.length, 0)
  108. assert.equal(blocked.blocked[0].reasonText, '零坐标(经纬度均为 0)')
  109. })
  110. check('C3 认不出的原因回「无地图位置」,不把原因码甩给界面', () => {
  111. const blocked = toPipeNetwork({ features: [feature({ properties: { sourceId: 'X-3', code: 'X-3', industry: 'GAS', coordinateValidation: { state: 'INVALID', reason: 'SOMETHING_NEW', renderable: false } } })] })
  112. assert.equal(blocked.blocked[0].reasonText, '无地图位置')
  113. })
  114. check('C4 点数不足的管段不得渲染(不补点、不截断成一点)', () => {
  115. const blocked = toPipeNetwork({ features: [feature({ geometry: { type: 'LineString', coordinates: [[110.39, 28.44]] } })] })
  116. assert.equal(blocked.pipes.length, 0)
  117. assert.equal(blocked.blocked.length, 1)
  118. })
  119. check('C5 缺少校验结论一律拦下("没说不"不等于"可以画")', () => {
  120. const blocked = toPipeNetwork({ features: [feature({ properties: { sourceId: 'X-4', code: 'X-4', industry: 'GAS' } })] })
  121. assert.equal(blocked.pipes.length, 0)
  122. assert.equal(blocked.blocked[0].reasonText, '坐标未校验')
  123. })
  124. check('C6 真实契约的非法坐标是 geometry:null,必须按类型正确归类且拦下', () => {
  125. // 后端对非法坐标回 geometry:null(原始坐标在 sourceCoordinates)——不能因此被当成管段或漏掉
  126. const componentNull = toPipeNetwork({ features: [component({ geometry: null, properties: { source: 'gas_pipe_point', sourceId: 'N-1', code: 'N-1', pointType: 'valve', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false } } })] })
  127. assert.equal(componentNull.components.length, 0)
  128. assert.equal(componentNull.blocked.length, 1)
  129. assert.equal(componentNull.blocked[0].kind, 'component', 'geometry:null 时按 typeCode 判为构件')
  130. assert.equal(componentNull.blocked[0].reasonText, '不在主场景区范围内')
  131. const pipeNull = toPipeNetwork({ features: [feature({ geometry: null, properties: { source: 'pipe_network_base', sourceId: 'N-2', code: 'N-2', industry: 'DRAINAGE', typeCode: 'PIPE', coordinateValidation: { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false } } })] })
  132. assert.equal(pipeNull.pipes.length, 0)
  133. assert.equal(pipeNull.blocked[0].kind, 'pipe', 'geometry:null 时按 typeCode 判为管段')
  134. })
  135. check('C7 新增的主场景区外原因有专属中文文案(后端 8013a207 引入)', () => {
  136. // 该 reason 已在真实数据里出现。若词表没有它,界面只会说笼统的「无地图位置」,
  137. // 值班人员就看不出"这条是因为落在主场景区外"——与「坐标未校验」是两回事。
  138. const blocked = toPipeNetwork({ features: [component({ geometry: null, properties: { source: 'gas_pipe_point', sourceId: 'N-3', code: 'N-3', pointType: 'valve', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', coordinateValidation: { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false } } })] })
  139. const text = blocked.blocked[0].reasonText
  140. assert.notEqual(text, '无地图位置', '必须有专属文案,不能退化成笼统兜底')
  141. assert.ok(text.includes('主场景区'), `文案要点明原因,实际:${text}`)
  142. })
  143. check('D1 行业筛选只留该行业,图层过滤只此一处', () => {
  144. const gas = filterPipeNetwork(network, { industry: 'gas' })
  145. assert.ok(gas.pipes.every((row) => row.industry === 'GAS'))
  146. assert.ok(gas.components.every((row) => row.industry === 'GAS'))
  147. })
  148. check('D2 全域筛选不裁剪', () => {
  149. const all = filterPipeNetwork(network, { industry: 'all' })
  150. assert.equal(all.pipes.length, network.pipes.length)
  151. })
  152. check('D3 片区筛选如实标记为未作用到管线(管线无片区字段)', () => {
  153. assert.equal(filterPipeNetwork(network, { industry: 'all' }).districtApplied, false)
  154. })
  155. check('E1 管径分档映射线宽,缺管径不炸', () => {
  156. assert.equal(pipeWidth(null), 2)
  157. assert.equal(pipeWidth(200), 2)
  158. assert.equal(pipeWidth(400), 3)
  159. assert.equal(pipeWidth(800), 4)
  160. assert.equal(pipeWidth(1200), 6)
  161. })
  162. check('E2 空载荷不抛,退化为空图层', () => {
  163. const empty = toPipeNetwork(null)
  164. assert.deepEqual(empty.pipes, [])
  165. assert.deepEqual(empty.components, [])
  166. assert.equal(empty.coverage.GAS.geometryAvailable, false)
  167. })
  168. console.log(`T24 管线图层: ${failed ? 'FAILED' : 'passed'}`)
  169. process.exitCode = failed ? 1 : 0