useTopologyLayout.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import {
  2. buildRelationArray,
  3. transformToTopologyData,
  4. LAYOUT,
  5. ROOT_IN_ID,
  6. ROOT_OUT_ID,
  7. } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
  8. import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
  9. import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
  10. /** 构造测风地点(MineArea) */
  11. const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
  12. id,
  13. name: id,
  14. level,
  15. airVolume,
  16. mineCode: 'M1',
  17. ...extra,
  18. });
  19. /** 构造巷道关系(MineAreaRelation) */
  20. const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
  21. id,
  22. mineCode: 'M1',
  23. parentId,
  24. childId,
  25. ...extra,
  26. });
  27. /** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系) */
  28. function laneTopo(): TopologyData {
  29. const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5)];
  30. const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
  31. return transformToTopologyData(areas, relations);
  32. }
  33. describe('buildRelationArray 关系数组生成', () => {
  34. test('过滤 parent/child 引用缺失的脏数据,保留关系主键 id', () => {
  35. const areas = [area('1', 1), area('2', 2)];
  36. const relations = [rel('r1', '1', '2'), rel('r2', '9', '2'), rel('r3', '1', '8'), rel('r4', '', '2')];
  37. const arr = buildRelationArray(areas, relations);
  38. expect(arr).toHaveLength(1);
  39. expect(arr[0].id).toBe('r1');
  40. expect(arr[0].parent.id).toBe('1');
  41. expect(arr[0].child.id).toBe('2');
  42. });
  43. });
  44. describe('transformToTopologyData 巷道线模型', () => {
  45. test('生成总进/总回地面节点;全部点位入节点;关系连线带 relationId/pointId;根边无 relationId;无布点节点;流向正确', () => {
  46. const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5), area('u1', 3, 10)];
  47. const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')];
  48. const data = transformToTopologyData(areas, buildRelationArray(areas, relations));
  49. // 总进/总回地面节点
  50. expect(data.nodes.some((n) => n.id === ROOT_IN_ID && n.name === '地面')).toBe(true);
  51. expect(data.nodes.some((n) => n.id === ROOT_OUT_ID && n.name === '地面')).toBe(true);
  52. expect(data.nodes.filter((n) => n.name === '地面')).toHaveLength(2);
  53. // 全部点位入节点(含无关系点位 u1)
  54. for (const a of areas) {
  55. expect(data.nodes.some((n) => n.id === `point:${a.id}`)).toBe(true);
  56. }
  57. // 关系连线带 relationId + pointId(子节点)
  58. const l = data.links.find((x) => x.relationId === 'r1');
  59. expect(l?.source).toBe('point:a1');
  60. expect(l?.target).toBe('point:b1');
  61. expect(l?.pointId).toBe('point:b1');
  62. // 主要数据点位:进风/用风取子节点、回风取父节点
  63. expect(data.links.find((x) => x.relationId === 'r1')?.mainPointId).toBe('point:b1'); // a1→b1 进风,子节点
  64. expect(data.links.find((x) => x.relationId === 'r2')?.mainPointId).toBe('point:c1'); // b1→c1 用风,子节点
  65. expect(data.links.find((x) => x.relationId === 'r3')?.mainPointId).toBe('point:c1'); // c1→d1 回风,父节点
  66. expect(data.links.find((x) => x.relationId === 'r4')?.mainPointId).toBe('point:d1'); // d1→e1 回风,父节点
  67. // 根边 地面→lv1、lv5→地面 均无 relationId,主要数据分别为 lv1 子节点 / lv5 父节点
  68. const rootIn = data.links.find((x) => x.source === ROOT_IN_ID && x.target === 'point:a1');
  69. expect(rootIn?.relationId).toBeUndefined();
  70. expect(rootIn?.mainPointId).toBe('point:a1');
  71. const rootOut = data.links.find((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID);
  72. expect(rootOut?.relationId).toBeUndefined();
  73. expect(rootOut?.mainPointId).toBe('point:e1');
  74. // 无测风装置节点
  75. expect(data.nodes.some((n) => n.category === 2)).toBe(false);
  76. // 流向:子节点 level ≤3 进风、>3 回风
  77. expect(data.links.find((x) => x.relationId === 'r1')?.flow).toBe('intake');
  78. expect(data.links.find((x) => x.relationId === 'r3')?.flow).toBe('return');
  79. });
  80. test('judgeAreaList 字段有内容 → suspected=true;无内容 → false(兼容 alarmList)', () => {
  81. const areas = [
  82. area('a1', 1, 100, { judgeAreaList: [{ id: 'x' }] }),
  83. area('b1', 2, 90, { judgeAreaList: null }),
  84. area('c1', 3, 80, { judgeAreaList: [] }),
  85. area('d1', 4, 70, { alarmList: ['y'] }), // 历史字段兼容
  86. ];
  87. const data = transformToTopologyData(areas, []);
  88. expect(data.nodes.find((n) => n.id === 'point:a1')?.suspected).toBe(true);
  89. expect(data.nodes.find((n) => n.id === 'point:b1')?.suspected).toBe(false);
  90. expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false);
  91. expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).toBe(true);
  92. });
  93. test('topologyX/topologyY 透传到节点(数字与字符串输入)', () => {
  94. const areas = [area('a1', 1, 100, { topologyX: 123.5, topologyY: 456 }), area('b1', 2, 90, { topologyX: '200', topologyY: '300' })];
  95. const data = transformToTopologyData(areas, []);
  96. expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyX).toBe(123.5);
  97. expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyY).toBe(456);
  98. expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyX).toBe('200');
  99. expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyY).toBe('300');
  100. // 地面节点无该字段
  101. expect(data.nodes.find((n) => n.id === ROOT_IN_ID)?.topologyX).toBeUndefined();
  102. });
  103. test('动态根边:有 lv0/lv6 时 总进→lv0、lv6→总回;缺失时回退 lv1/lv5', () => {
  104. // 场景一:存在 lv0(进风井) 与 lv6(回风井) → 根边连 lv0/lv6,不再连 lv1/lv5
  105. const areas1 = [area('z0', 0), area('a1', 1), area('e1', 5), area('z6', 6)];
  106. const data1 = transformToTopologyData(areas1, []);
  107. expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:z0')).toBe(true);
  108. expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(false);
  109. expect(data1.links.some((x) => x.source === 'point:z6' && x.target === ROOT_OUT_ID)).toBe(true);
  110. expect(data1.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(false);
  111. // 场景二:无 lv0/lv6 → 回退 总进→lv1、lv5→总回
  112. const areas2 = [area('a1', 1), area('e1', 5)];
  113. const data2 = transformToTopologyData(areas2, []);
  114. expect(data2.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(true);
  115. expect(data2.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(true);
  116. });
  117. });
  118. describe('computeLayout 列式布局(固定间距)', () => {
  119. const centerY = 350; // 700 / 2
  120. const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
  121. test('列序:总进 → lv0..lv6 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => {
  122. const layout = computeLayout(laneTopo(), 1200, 700);
  123. const pos = layout.positions;
  124. expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
  125. // 列序:总进(0) → lv0(1) → lv1(2) → lv2(3) → lv3(4) → lv4(5) → lv5(6) → lv6(7) → 未分级(8) → 总回(9)
  126. expect(pos['point:a1'].x).toBe(laneX(2));
  127. expect(pos['point:b1'].x).toBe(laneX(3));
  128. expect(pos['point:c1'].x).toBe(laneX(4));
  129. expect(pos['point:d1'].x).toBe(laneX(5));
  130. expect(pos['point:e1'].x).toBe(laneX(6));
  131. expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
  132. expect(pos[ROOT_IN_ID].y).toBe(centerY);
  133. expect(pos[ROOT_OUT_ID].y).toBe(centerY);
  134. });
  135. test('新增层级 lv0(进风井)/lv6(回风井) 列序:位于 lv1 之前、lv5 之后', () => {
  136. const areas = [area('a1', 1), area('z0', 0), area('e1', 5), area('z6', 6)];
  137. const data = transformToTopologyData(areas, []);
  138. const pos = computeLayout(data, 1200, 700).positions;
  139. expect(pos['point:z0'].x).toBe(laneX(1)); // lv0 在 lv1 前
  140. expect(pos['point:a1'].x).toBe(laneX(2));
  141. expect(pos['point:e1'].x).toBe(laneX(6));
  142. expect(pos['point:z6'].x).toBe(laneX(7)); // lv6 在 lv5 后
  143. expect(pos[ROOT_OUT_ID].x).toBe(laneX(9));
  144. });
  145. test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => {
  146. const data = laneTopo();
  147. data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
  148. const pos = computeLayout(data, 1200, 700).positions;
  149. expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
  150. const expected = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
  151. expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(expected);
  152. expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
  153. });
  154. test('colGap 固定生效', () => {
  155. const orig = LAYOUT.colGap;
  156. try {
  157. LAYOUT.colGap = 250;
  158. const pos = computeLayout(laneTopo(), 1200, 700).positions;
  159. // 相邻层级列间距 = colGap(a1 为 lv1,与总进之间隔 lv0 空列)
  160. expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250);
  161. expect(pos['point:c1'].x - pos['point:b1'].x).toBe(250);
  162. } finally {
  163. LAYOUT.colGap = orig;
  164. }
  165. });
  166. test('rowGap 按 level 分别配置生效:只影响对应层级列', () => {
  167. const orig2 = LAYOUT.rowGap[2];
  168. try {
  169. LAYOUT.rowGap[2] = 300;
  170. const data = laneTopo();
  171. data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
  172. data.nodes.push({ id: 'point:c2', name: 'c2', category: 1, level: 3, airVolume: 75 });
  173. const pos = computeLayout(data, 1200, 700).positions;
  174. // lv2 列使用本次配置值 300
  175. expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(300);
  176. // lv3 列仍使用其自身配置值(未被本次修改影响)
  177. const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
  178. expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(gap3);
  179. // 两列分别围绕 centerY 对称
  180. expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
  181. expect((pos['point:c1'].y + pos['point:c2'].y) / 2).toBeCloseTo(centerY, 5);
  182. } finally {
  183. LAYOUT.rowGap[2] = orig2;
  184. }
  185. });
  186. test('多条进风-用风关系数据:全部点位有合法坐标、无 NaN、不越界', () => {
  187. const areas = [area('a1', 1, 500), area('a2', 1, 400), area('c1', 3, 300), area('c2', 3, 200), area('e1', 5, 100), area('e2', 5, 90)];
  188. const relations = buildRelationArray(areas, [rel('r1', 'a1', 'c1'), rel('r2', 'a2', 'c2'), rel('r3', 'c1', 'e1'), rel('r4', 'c2', 'e2')]);
  189. const data = transformToTopologyData(areas, relations);
  190. const layout = computeLayout(data, 1200, 700);
  191. for (const a of areas) {
  192. const pos = layout.positions[`point:${a.id}`];
  193. expect(pos).toBeDefined();
  194. expect(Number.isFinite(pos.x)).toBe(true);
  195. expect(Number.isFinite(pos.y)).toBe(true);
  196. expect(pos.y).toBeGreaterThanOrEqual(0);
  197. expect(pos.y).toBeLessThan(700);
  198. }
  199. // 两个进风点同列(lv1)、两个用风点同列(lv3)、两个回风点同列(lv5)
  200. expect(layout.positions['point:a1'].x).toBe(layout.positions['point:a2'].x);
  201. expect(layout.positions['point:c1'].x).toBe(layout.positions['point:c2'].x);
  202. expect(layout.positions['point:e1'].x).toBe(layout.positions['point:e2'].x);
  203. // 各 2 节点列按各自层级行距围绕 centerY 对称(首节点 = centerY − 行距/2)
  204. const gap1 = LAYOUT.rowGap[1] ?? LAYOUT.rowGapDefault;
  205. const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
  206. const gap5 = LAYOUT.rowGap[5] ?? LAYOUT.rowGapDefault;
  207. expect(layout.positions['point:a1'].y).toBeCloseTo(centerY - gap1 / 2, 5);
  208. expect(layout.positions['point:c1'].y).toBeCloseTo(centerY - gap3 / 2, 5);
  209. expect(layout.positions['point:e1'].y).toBeCloseTo(centerY - gap5 / 2, 5);
  210. expect((layout.positions['point:a1'].y + layout.positions['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
  211. });
  212. test('不同节点数列独立垂直居中:1/2/4 节点列各自围绕 centerY 对称,单节点列在 centerY', () => {
  213. // 列分布:总进(1) lv1(1) lv2(2) lv3(4) lv4(2) lv5(1) 总回(1)
  214. const areas = [
  215. area('a1', 1, 500),
  216. area('b1', 2, 300),
  217. area('b2', 2, 200),
  218. area('c1', 3, 400),
  219. area('c2', 3, 300),
  220. area('c3', 3, 200),
  221. area('c4', 3, 100),
  222. area('d1', 4, 250),
  223. area('d2', 4, 150),
  224. area('e1', 5, 100),
  225. ];
  226. const data = transformToTopologyData(areas, []);
  227. const layout = computeLayout(data, 1200, 700);
  228. const pos = layout.positions;
  229. // 单节点列(总进、lv1、lv5、总回)垂直居中
  230. expect(pos[ROOT_IN_ID].y).toBe(centerY);
  231. expect(pos['point:a1'].y).toBe(centerY);
  232. expect(pos['point:e1'].y).toBe(centerY);
  233. expect(pos[ROOT_OUT_ID].y).toBe(centerY);
  234. // 2 节点列(lv2/lv4)围绕 centerY 对称,中点 = centerY,行距取各自层级配置
  235. const gap2 = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
  236. const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
  237. const gap4 = LAYOUT.rowGap[4] ?? LAYOUT.rowGapDefault;
  238. expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
  239. expect((pos['point:d1'].y + pos['point:d2'].y) / 2).toBeCloseTo(centerY, 5);
  240. expect(Math.abs(pos['point:b1'].y - pos['point:b2'].y)).toBe(gap2);
  241. expect(Math.abs(pos['point:d1'].y - pos['point:d2'].y)).toBe(gap4);
  242. // 4 节点列(lv3)围绕 centerY 对称,中点 = centerY,跨度 = 3×该层级行距
  243. expect((pos['point:c1'].y + pos['point:c4'].y) / 2).toBeCloseTo(centerY, 5);
  244. expect(Math.abs(pos['point:c1'].y - pos['point:c4'].y)).toBe(3 * gap3);
  245. // 中间列跨度大于两端列(菱形轮廓)
  246. expect(3 * gap3).toBeGreaterThan(gap2);
  247. expect(gap2).toBeGreaterThan(0);
  248. });
  249. test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {
  250. const layout = computeLayout(
  251. {
  252. nodes: [
  253. { id: ROOT_IN_ID, name: '地面', category: 0 },
  254. { id: ROOT_OUT_ID, name: '地面', category: 0 },
  255. ],
  256. links: [],
  257. },
  258. 1200,
  259. 700
  260. );
  261. expect(layout.positions[ROOT_IN_ID]).toBeDefined();
  262. expect(layout.positions[ROOT_OUT_ID]).toBeDefined();
  263. expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
  264. expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
  265. });
  266. test('布局保存坐标覆盖:有效坐标直接使用;null/空串/0/NaN 等无效坐标回退列布局(不挤到原点)', () => {
  267. const areas = [
  268. area('a1', 1, 100, { topologyX: 888, topologyY: 666 }),
  269. area('b1', 2, 90, { topologyX: '999.5', topologyY: '333' }), // 字符串数字同样生效
  270. area('c1', 3, 80, { topologyX: 'abc', topologyY: NaN }), // 非数字 → 回退
  271. area('d1', 4, 70), // 无坐标 → 回退
  272. area('e1', 5, 60, { topologyX: 0, topologyY: null }), // 后端默认 0/null → 回退
  273. area('f1', 0, 50, { topologyX: '', topologyY: '0' }), // 空串/'0' → 回退(lv0 进风井列)
  274. ];
  275. const data = transformToTopologyData(areas, []);
  276. const pos = computeLayout(data, 1200, 700).positions;
  277. // 有效保存坐标直接使用
  278. expect(pos['point:a1']).toEqual({ x: 888, y: 666 });
  279. expect(pos['point:b1']).toEqual({ x: 999.5, y: 333 });
  280. // c1 非法坐标回退列布局(lv3 列,垂直居中),而非覆盖到 (0,0)
  281. expect(pos['point:c1'].x).toBe(laneX(4)); // 总进(0) lv0(1) lv1(2) lv2(3) lv3(4)
  282. expect(pos['point:c1'].y).toBe(centerY);
  283. // 无坐标节点正常列布局
  284. expect(pos['point:d1'].x).toBe(laneX(5)); // lv4
  285. // 后端默认 0/null/''/0 字符串:回退列布局,绝不挤到原点
  286. expect(pos['point:e1'].x).toBe(laneX(6)); // lv5
  287. expect(pos['point:e1'].y).toBe(centerY);
  288. expect(pos['point:f1'].x).toBe(laneX(1)); // lv0 进风井列
  289. expect(pos['point:f1'].y).toBe(centerY);
  290. expect(pos['point:e1'].x).not.toBe(0);
  291. expect(pos['point:f1'].y).not.toBe(0);
  292. });
  293. });