import { buildRelationArray, transformToTopologyData, LAYOUT, ROOT_IN_ID, ROOT_OUT_ID, } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data'; import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data'; import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout'; /** 构造测风地点(MineArea) */ const area = (id: string, level: number, airVolume = 100, extra: Record = {}) => ({ id, name: id, level, airVolume, mineCode: 'M1', ...extra, }); /** 构造巷道关系(MineAreaRelation) */ const rel = (id: string, parentId: string, childId: string, extra: Record = {}) => ({ id, mineCode: 'M1', parentId, childId, ...extra, }); /** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系) */ function laneTopo(): TopologyData { const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5)]; const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]); return transformToTopologyData(areas, relations); } describe('buildRelationArray 关系数组生成', () => { test('过滤 parent/child 引用缺失的脏数据,保留关系主键 id', () => { const areas = [area('1', 1), area('2', 2)]; const relations = [rel('r1', '1', '2'), rel('r2', '9', '2'), rel('r3', '1', '8'), rel('r4', '', '2')]; const arr = buildRelationArray(areas, relations); expect(arr).toHaveLength(1); expect(arr[0].id).toBe('r1'); expect(arr[0].parent.id).toBe('1'); expect(arr[0].child.id).toBe('2'); }); }); describe('transformToTopologyData 巷道线模型', () => { test('生成总进/总回地面节点;全部点位入节点;关系连线带 relationId/pointId;根边无 relationId;无布点节点;流向正确', () => { const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5), area('u1', 3, 10)]; const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]; const data = transformToTopologyData(areas, buildRelationArray(areas, relations)); // 总进/总回地面节点 expect(data.nodes.some((n) => n.id === ROOT_IN_ID && n.name === '地面')).toBe(true); expect(data.nodes.some((n) => n.id === ROOT_OUT_ID && n.name === '地面')).toBe(true); expect(data.nodes.filter((n) => n.name === '地面')).toHaveLength(2); // 全部点位入节点(含无关系点位 u1) for (const a of areas) { expect(data.nodes.some((n) => n.id === `point:${a.id}`)).toBe(true); } // 关系连线带 relationId + pointId(子节点) const l = data.links.find((x) => x.relationId === 'r1'); expect(l?.source).toBe('point:a1'); expect(l?.target).toBe('point:b1'); expect(l?.pointId).toBe('point:b1'); // 主要数据点位:进风/用风取子节点、回风取父节点 expect(data.links.find((x) => x.relationId === 'r1')?.mainPointId).toBe('point:b1'); // a1→b1 进风,子节点 expect(data.links.find((x) => x.relationId === 'r2')?.mainPointId).toBe('point:c1'); // b1→c1 用风,子节点 expect(data.links.find((x) => x.relationId === 'r3')?.mainPointId).toBe('point:c1'); // c1→d1 回风,父节点 expect(data.links.find((x) => x.relationId === 'r4')?.mainPointId).toBe('point:d1'); // d1→e1 回风,父节点 // 根边 地面→lv1、lv5→地面 均无 relationId,主要数据分别为 lv1 子节点 / lv5 父节点 const rootIn = data.links.find((x) => x.source === ROOT_IN_ID && x.target === 'point:a1'); expect(rootIn?.relationId).toBeUndefined(); expect(rootIn?.mainPointId).toBe('point:a1'); const rootOut = data.links.find((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID); expect(rootOut?.relationId).toBeUndefined(); expect(rootOut?.mainPointId).toBe('point:e1'); // 无测风装置节点 expect(data.nodes.some((n) => n.category === 2)).toBe(false); // 流向:子节点 level ≤3 进风、>3 回风 expect(data.links.find((x) => x.relationId === 'r1')?.flow).toBe('intake'); expect(data.links.find((x) => x.relationId === 'r3')?.flow).toBe('return'); }); test('judgeAreaList 字段有内容 → suspected=true;无内容 → false(兼容 alarmList)', () => { const areas = [ area('a1', 1, 100, { judgeAreaList: [{ id: 'x' }] }), area('b1', 2, 90, { judgeAreaList: null }), area('c1', 3, 80, { judgeAreaList: [] }), area('d1', 4, 70, { alarmList: ['y'] }), // 历史字段兼容 ]; const data = transformToTopologyData(areas, []); expect(data.nodes.find((n) => n.id === 'point:a1')?.suspected).toBe(true); expect(data.nodes.find((n) => n.id === 'point:b1')?.suspected).toBe(false); expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false); expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).toBe(true); }); test('topologyX/topologyY 透传到节点(数字与字符串输入)', () => { const areas = [area('a1', 1, 100, { topologyX: 123.5, topologyY: 456 }), area('b1', 2, 90, { topologyX: '200', topologyY: '300' })]; const data = transformToTopologyData(areas, []); expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyX).toBe(123.5); expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyY).toBe(456); expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyX).toBe('200'); expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyY).toBe('300'); // 地面节点无该字段 expect(data.nodes.find((n) => n.id === ROOT_IN_ID)?.topologyX).toBeUndefined(); }); test('动态根边:有 lv0/lv6 时 总进→lv0、lv6→总回;缺失时回退 lv1/lv5', () => { // 场景一:存在 lv0(进风井) 与 lv6(回风井) → 根边连 lv0/lv6,不再连 lv1/lv5 const areas1 = [area('z0', 0), area('a1', 1), area('e1', 5), area('z6', 6)]; const data1 = transformToTopologyData(areas1, []); expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:z0')).toBe(true); expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(false); expect(data1.links.some((x) => x.source === 'point:z6' && x.target === ROOT_OUT_ID)).toBe(true); expect(data1.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(false); // 场景二:无 lv0/lv6 → 回退 总进→lv1、lv5→总回 const areas2 = [area('a1', 1), area('e1', 5)]; const data2 = transformToTopologyData(areas2, []); expect(data2.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(true); expect(data2.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(true); }); }); describe('computeLayout 列式布局(固定间距)', () => { const centerY = 350; // 700 / 2 const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx; test('列序:总进 → lv0..lv6 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => { const layout = computeLayout(laneTopo(), 1200, 700); const pos = layout.positions; expect(pos[ROOT_IN_ID].x).toBe(laneX(0)); // 列序:总进(0) → lv0(1) → lv1(2) → lv2(3) → lv3(4) → lv4(5) → lv5(6) → lv6(7) → 未分级(8) → 总回(9) expect(pos['point:a1'].x).toBe(laneX(2)); expect(pos['point:b1'].x).toBe(laneX(3)); expect(pos['point:c1'].x).toBe(laneX(4)); expect(pos['point:d1'].x).toBe(laneX(5)); expect(pos['point:e1'].x).toBe(laneX(6)); expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回 expect(pos[ROOT_IN_ID].y).toBe(centerY); expect(pos[ROOT_OUT_ID].y).toBe(centerY); }); test('新增层级 lv0(进风井)/lv6(回风井) 列序:位于 lv1 之前、lv5 之后', () => { const areas = [area('a1', 1), area('z0', 0), area('e1', 5), area('z6', 6)]; const data = transformToTopologyData(areas, []); const pos = computeLayout(data, 1200, 700).positions; expect(pos['point:z0'].x).toBe(laneX(1)); // lv0 在 lv1 前 expect(pos['point:a1'].x).toBe(laneX(2)); expect(pos['point:e1'].x).toBe(laneX(6)); expect(pos['point:z6'].x).toBe(laneX(7)); // lv6 在 lv5 后 expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); }); test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => { const data = laneTopo(); data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 }); const pos = computeLayout(data, 1200, 700).positions; expect(pos['point:b2'].x).toBe(pos['point:b1'].x); const expected = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault; expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(expected); expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5); }); test('colGap 固定生效', () => { const orig = LAYOUT.colGap; try { LAYOUT.colGap = 250; const pos = computeLayout(laneTopo(), 1200, 700).positions; // 相邻层级列间距 = colGap(a1 为 lv1,与总进之间隔 lv0 空列) expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250); expect(pos['point:c1'].x - pos['point:b1'].x).toBe(250); } finally { LAYOUT.colGap = orig; } }); test('rowGap 按 level 分别配置生效:只影响对应层级列', () => { const orig2 = LAYOUT.rowGap[2]; try { LAYOUT.rowGap[2] = 300; const data = laneTopo(); data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 }); data.nodes.push({ id: 'point:c2', name: 'c2', category: 1, level: 3, airVolume: 75 }); const pos = computeLayout(data, 1200, 700).positions; // lv2 列使用本次配置值 300 expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(300); // lv3 列仍使用其自身配置值(未被本次修改影响) const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault; expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(gap3); // 两列分别围绕 centerY 对称 expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5); expect((pos['point:c1'].y + pos['point:c2'].y) / 2).toBeCloseTo(centerY, 5); } finally { LAYOUT.rowGap[2] = orig2; } }); test('多条进风-用风关系数据:全部点位有合法坐标、无 NaN、不越界', () => { 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)]; const relations = buildRelationArray(areas, [rel('r1', 'a1', 'c1'), rel('r2', 'a2', 'c2'), rel('r3', 'c1', 'e1'), rel('r4', 'c2', 'e2')]); const data = transformToTopologyData(areas, relations); const layout = computeLayout(data, 1200, 700); for (const a of areas) { const pos = layout.positions[`point:${a.id}`]; expect(pos).toBeDefined(); expect(Number.isFinite(pos.x)).toBe(true); expect(Number.isFinite(pos.y)).toBe(true); expect(pos.y).toBeGreaterThanOrEqual(0); expect(pos.y).toBeLessThan(700); } // 两个进风点同列(lv1)、两个用风点同列(lv3)、两个回风点同列(lv5) expect(layout.positions['point:a1'].x).toBe(layout.positions['point:a2'].x); expect(layout.positions['point:c1'].x).toBe(layout.positions['point:c2'].x); expect(layout.positions['point:e1'].x).toBe(layout.positions['point:e2'].x); // 各 2 节点列按各自层级行距围绕 centerY 对称(首节点 = centerY − 行距/2) const gap1 = LAYOUT.rowGap[1] ?? LAYOUT.rowGapDefault; const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault; const gap5 = LAYOUT.rowGap[5] ?? LAYOUT.rowGapDefault; expect(layout.positions['point:a1'].y).toBeCloseTo(centerY - gap1 / 2, 5); expect(layout.positions['point:c1'].y).toBeCloseTo(centerY - gap3 / 2, 5); expect(layout.positions['point:e1'].y).toBeCloseTo(centerY - gap5 / 2, 5); expect((layout.positions['point:a1'].y + layout.positions['point:a2'].y) / 2).toBeCloseTo(centerY, 5); }); test('不同节点数列独立垂直居中:1/2/4 节点列各自围绕 centerY 对称,单节点列在 centerY', () => { // 列分布:总进(1) lv1(1) lv2(2) lv3(4) lv4(2) lv5(1) 总回(1) const areas = [ area('a1', 1, 500), area('b1', 2, 300), area('b2', 2, 200), area('c1', 3, 400), area('c2', 3, 300), area('c3', 3, 200), area('c4', 3, 100), area('d1', 4, 250), area('d2', 4, 150), area('e1', 5, 100), ]; const data = transformToTopologyData(areas, []); const layout = computeLayout(data, 1200, 700); const pos = layout.positions; // 单节点列(总进、lv1、lv5、总回)垂直居中 expect(pos[ROOT_IN_ID].y).toBe(centerY); expect(pos['point:a1'].y).toBe(centerY); expect(pos['point:e1'].y).toBe(centerY); expect(pos[ROOT_OUT_ID].y).toBe(centerY); // 2 节点列(lv2/lv4)围绕 centerY 对称,中点 = centerY,行距取各自层级配置 const gap2 = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault; const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault; const gap4 = LAYOUT.rowGap[4] ?? LAYOUT.rowGapDefault; expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5); expect((pos['point:d1'].y + pos['point:d2'].y) / 2).toBeCloseTo(centerY, 5); expect(Math.abs(pos['point:b1'].y - pos['point:b2'].y)).toBe(gap2); expect(Math.abs(pos['point:d1'].y - pos['point:d2'].y)).toBe(gap4); // 4 节点列(lv3)围绕 centerY 对称,中点 = centerY,跨度 = 3×该层级行距 expect((pos['point:c1'].y + pos['point:c4'].y) / 2).toBeCloseTo(centerY, 5); expect(Math.abs(pos['point:c1'].y - pos['point:c4'].y)).toBe(3 * gap3); // 中间列跨度大于两端列(菱形轮廓) expect(3 * gap3).toBeGreaterThan(gap2); expect(gap2).toBeGreaterThan(0); }); test('空数据(仅总进/总回地面节点)可布局且不抛错', () => { const layout = computeLayout( { nodes: [ { id: ROOT_IN_ID, name: '地面', category: 0 }, { id: ROOT_OUT_ID, name: '地面', category: 0 }, ], links: [], }, 1200, 700 ); expect(layout.positions[ROOT_IN_ID]).toBeDefined(); expect(layout.positions[ROOT_OUT_ID]).toBeDefined(); expect(layout.positions[ROOT_IN_ID].y).toBe(centerY); expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY); }); test('布局保存坐标覆盖:有效坐标直接使用;null/空串/0/NaN 等无效坐标回退列布局(不挤到原点)', () => { const areas = [ area('a1', 1, 100, { topologyX: 888, topologyY: 666 }), area('b1', 2, 90, { topologyX: '999.5', topologyY: '333' }), // 字符串数字同样生效 area('c1', 3, 80, { topologyX: 'abc', topologyY: NaN }), // 非数字 → 回退 area('d1', 4, 70), // 无坐标 → 回退 area('e1', 5, 60, { topologyX: 0, topologyY: null }), // 后端默认 0/null → 回退 area('f1', 0, 50, { topologyX: '', topologyY: '0' }), // 空串/'0' → 回退(lv0 进风井列) ]; const data = transformToTopologyData(areas, []); const pos = computeLayout(data, 1200, 700).positions; // 有效保存坐标直接使用 expect(pos['point:a1']).toEqual({ x: 888, y: 666 }); expect(pos['point:b1']).toEqual({ x: 999.5, y: 333 }); // c1 非法坐标回退列布局(lv3 列,垂直居中),而非覆盖到 (0,0) expect(pos['point:c1'].x).toBe(laneX(4)); // 总进(0) lv0(1) lv1(2) lv2(3) lv3(4) expect(pos['point:c1'].y).toBe(centerY); // 无坐标节点正常列布局 expect(pos['point:d1'].x).toBe(laneX(5)); // lv4 // 后端默认 0/null/''/0 字符串:回退列布局,绝不挤到原点 expect(pos['point:e1'].x).toBe(laneX(6)); // lv5 expect(pos['point:e1'].y).toBe(centerY); expect(pos['point:f1'].x).toBe(laneX(1)); // lv0 进风井列 expect(pos['point:f1'].y).toBe(centerY); expect(pos['point:e1'].x).not.toBe(0); expect(pos['point:f1'].y).not.toBe(0); }); });