|
|
@@ -0,0 +1,360 @@
|
|
|
+import {
|
|
|
+ buildVentilationPaths,
|
|
|
+ extractDistrictKeys,
|
|
|
+ formatAirVolumeLabel,
|
|
|
+ transformToTopologyData,
|
|
|
+ LAYOUT,
|
|
|
+} 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<string, any> = {}) => ({
|
|
|
+ id,
|
|
|
+ name: id,
|
|
|
+ level,
|
|
|
+ airVolume,
|
|
|
+ mineCode: 'M1',
|
|
|
+ ...extra,
|
|
|
+});
|
|
|
+
|
|
|
+/** 构造一个完整的通风回路拓扑(单行示意图数据) */
|
|
|
+function schematicTopo(): TopologyData {
|
|
|
+ const nodes = [
|
|
|
+ { id: 'org:m1', name: '某矿', category: 0 },
|
|
|
+ { id: 'point:a1', name: '矿井进风1', category: 1, level: 1, airVolume: 100 },
|
|
|
+ { id: 'point:b1', name: '采区进风1', category: 1, level: 2, airVolume: 90 },
|
|
|
+ { id: 'point:b2', name: '采区进风2', category: 1, level: 2, airVolume: 80 },
|
|
|
+ { id: 'point:c1', name: '采区用风1', category: 1, level: 3, airVolume: 70 },
|
|
|
+ { id: 'point:d1', name: '采区回风1', category: 1, level: 4, airVolume: 60 },
|
|
|
+ { id: 'point:e1', name: '矿井回风1', category: 1, level: 5, airVolume: 50 },
|
|
|
+ { id: 'device:w1', name: '传感器1', category: 2, status: 'normal' },
|
|
|
+ { id: 'device:w2', name: '传感器2', category: 2, status: 'offline' },
|
|
|
+ ];
|
|
|
+ const links = [
|
|
|
+ { source: 'org:m1', target: 'point:a1', kind: 'roadway', flow: 'intake', pointId: 'point:a1' },
|
|
|
+ { source: 'point:a1', target: 'point:b1', kind: 'roadway', flow: 'intake', pointId: 'point:b1' },
|
|
|
+ { source: 'point:a1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' },
|
|
|
+ { source: 'point:b1', target: 'point:c1', kind: 'roadway', flow: 'intake', pointId: 'point:c1' },
|
|
|
+ { source: 'point:c1', target: 'point:d1', kind: 'roadway', flow: 'return', pointId: 'point:d1' },
|
|
|
+ { source: 'point:d1', target: 'point:e1', kind: 'roadway', flow: 'return', pointId: 'point:e1' },
|
|
|
+ { source: 'point:e1', target: 'org:m1', kind: 'roadway', flow: 'return' },
|
|
|
+ { source: 'point:b1', target: 'device:w1', kind: 'sensor' },
|
|
|
+ ];
|
|
|
+ return { nodes, links } as unknown as TopologyData;
|
|
|
+}
|
|
|
+
|
|
|
+describe('extractDistrictKeys 采区标识提取', () => {
|
|
|
+ test('数字编号优先', () => {
|
|
|
+ expect(extractDistrictKeys('101回风顺槽')).toContain('101');
|
|
|
+ expect(extractDistrictKeys('3-1煤层回风')).toContain('3-1');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('采区/盘区词', () => {
|
|
|
+ expect(extractDistrictKeys('北一采区进风')).toContain('北一采区');
|
|
|
+ expect(extractDistrictKeys('101采区回风')).toContain('101采区');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('无标识返回空', () => {
|
|
|
+ expect(extractDistrictKeys('总回风')).toEqual([]);
|
|
|
+ expect(extractDistrictKeys('')).toEqual([]);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('buildVentilationPaths 前端智能路径计算', () => {
|
|
|
+ test('层级跳级补齐:无 L4 时 L3(回风顺槽)直接连 L5,回路闭合', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('b1', 2, 90, { name: '一采区进风' }),
|
|
|
+ area('c1', 3, 80, { name: '101回风顺槽' }),
|
|
|
+ area('e1', 5, 70, { name: '总回风' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ // 跨级边 L3→L5(回风)
|
|
|
+ expect(links.some((l) => l.kind === 'roadway' && l.source === 'point:c1' && l.target === 'point:e1' && l.flow === 'return')).toBe(true);
|
|
|
+ // 矿井起边 → L1、闭合边 L5 → 矿井(无 pointId)
|
|
|
+ expect(links.some((l) => l.kind === 'roadway' && l.source === 'org:m1' && l.target === 'point:a1' && l.flow === 'intake')).toBe(true);
|
|
|
+ expect(links.some((l) => l.kind === 'roadway' && l.source === 'point:e1' && l.target === 'org:m1' && l.flow === 'return' && !l.pointId)).toBe(
|
|
|
+ true
|
|
|
+ );
|
|
|
+ // 每个点位都有完整回路路径:c1 有入边 b1→c1、出边 c1→e1
|
|
|
+ expect(links.some((l) => l.source === 'point:b1' && l.target === 'point:c1')).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('名称分组:同采区标识相连(101 顺槽→101 回风,102 顺槽→102 回风)', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('b1', 2, 90, { name: '一采区进风' }),
|
|
|
+ area('c1', 3, 80, { name: '101回风顺槽' }),
|
|
|
+ area('c2', 3, 70, { name: '102回风顺槽' }),
|
|
|
+ area('d1', 4, 60, { name: '101采区回风' }),
|
|
|
+ area('d2', 4, 50, { name: '102采区回风' }),
|
|
|
+ area('e1', 5, 40, { name: '总回风' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ // L3→L4 按采区标识匹配,而非数量均分
|
|
|
+ expect(links.filter((l) => l.kind === 'roadway' && l.source === 'point:c1').map((l) => l.target)).toEqual(['point:d1']);
|
|
|
+ expect(links.filter((l) => l.kind === 'roadway' && l.source === 'point:c2').map((l) => l.target)).toEqual(['point:d2']);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('均分兜底:无采区标识的子点每个恰一条入边且父点负载均衡', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('a2', 1, 90, { name: '副井进风' }),
|
|
|
+ area('b1', 2, 80, { name: '一号顺槽' }),
|
|
|
+ area('b2', 2, 70, { name: '二号顺槽' }),
|
|
|
+ area('b3', 2, 60, { name: '三号顺槽' }),
|
|
|
+ area('b4', 2, 50, { name: '四号顺槽' }),
|
|
|
+ area('b5', 2, 40, { name: '五号顺槽' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ const l12 = links.filter((l) => l.kind === 'roadway' && l.target.startsWith('point:b'));
|
|
|
+ for (const id of ['b1', 'b2', 'b3', 'b4', 'b5']) {
|
|
|
+ expect(l12.filter((l) => l.target === `point:${id}`)).toHaveLength(1);
|
|
|
+ }
|
|
|
+ // 2 父 5 子 → 3/2 均分
|
|
|
+ const loads = ['a1', 'a2'].map((p) => l12.filter((l) => l.source === `point:${p}`).length);
|
|
|
+ expect(loads.reduce((a, b) => a + b, 0)).toBe(5);
|
|
|
+ expect(Math.max(...loads) - Math.min(...loads)).toBeLessThanOrEqual(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('起止边取首个/末个存在层级:无 L1 时矿井→L2,无 L5 时 L4→矿井', () => {
|
|
|
+ const points = [
|
|
|
+ area('b1', 2, 90, { name: '一采区进风' }),
|
|
|
+ area('c1', 3, 80, { name: '101回风顺槽' }),
|
|
|
+ area('d1', 4, 70, { name: '101采区回风' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ expect(links.some((l) => l.source === 'org:m1' && l.target === 'point:b1' && l.flow === 'intake')).toBe(true);
|
|
|
+ expect(links.some((l) => l.source === 'point:d1' && l.target === 'org:m1' && l.flow === 'return' && !l.pointId)).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('未分级点位按名称关键字分侧:回风侧从末层级扇出,进风侧从首层级扇出', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('c1', 3, 80, { name: '101回风顺槽' }),
|
|
|
+ area('e1', 5, 70, { name: '总回风' }),
|
|
|
+ area('u1', 0, 60, { name: '北翼回风大巷' }), // 未分级,含"回风"
|
|
|
+ area('u2', 0, 50, { name: '主斜井进风' }), // 未分级,含"进风"
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ expect(links.some((l) => l.source === 'point:e1' && l.target === 'point:u1' && l.flow === 'return')).toBe(true);
|
|
|
+ expect(links.some((l) => l.source === 'point:a1' && l.target === 'point:u2' && l.flow === 'intake')).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('回风侧 fan-in:5 个 L3(回风顺槽)对 1 个 L4 → 恰 5 条连线,pointId 指向源点位', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('b1', 2, 90, { name: '一采区进风' }),
|
|
|
+ area('c1', 3, 80, { name: '一号回风顺槽' }),
|
|
|
+ area('c2', 3, 70, { name: '二号回风顺槽' }),
|
|
|
+ area('c3', 3, 60, { name: '三号回风顺槽' }),
|
|
|
+ area('c4', 3, 50, { name: '四号回风顺槽' }),
|
|
|
+ area('c5', 3, 40, { name: '五号回风顺槽' }),
|
|
|
+ area('d1', 4, 30, { name: '一采区回风' }),
|
|
|
+ area('e1', 5, 20, { name: '总回风' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ const l3l4 = links.filter((l) => l.kind === 'roadway' && l.target === 'point:d1' && l.source.startsWith('point:c'));
|
|
|
+ expect(l3l4).toHaveLength(5);
|
|
|
+ for (const id of ['c1', 'c2', 'c3', 'c4', 'c5']) {
|
|
|
+ const edges = links.filter((l) => l.source === `point:${id}` && l.target === 'point:d1' && l.flow === 'return');
|
|
|
+ expect(edges).toHaveLength(1);
|
|
|
+ expect(edges[0].pointId).toBe(`point:${id}`);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('L4→L5 同样 fan-in:3 个采区回风各连一条到矿井回风', () => {
|
|
|
+ const points = [
|
|
|
+ area('a1', 1, 100, { name: '主井进风' }),
|
|
|
+ area('b1', 2, 90, { name: '一采区进风' }),
|
|
|
+ area('c1', 3, 80, { name: '101回风顺槽' }),
|
|
|
+ area('d1', 4, 70, { name: '101采区回风' }),
|
|
|
+ area('d2', 4, 60, { name: '102采区回风' }),
|
|
|
+ area('d3', 4, 50, { name: '103采区回风' }),
|
|
|
+ area('e1', 5, 40, { name: '总回风' }),
|
|
|
+ ];
|
|
|
+ const links = buildVentilationPaths(points, 'org:m1');
|
|
|
+ const l45 = links.filter((l) => l.kind === 'roadway' && l.target === 'point:e1' && l.source.startsWith('point:d'));
|
|
|
+ expect(l45).toHaveLength(3);
|
|
|
+ for (const id of ['d1', 'd2', 'd3']) {
|
|
|
+ expect(links.some((l) => l.source === `point:${id}` && l.target === 'point:e1' && l.pointId === `point:${id}`)).toBe(true);
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('transformToTopologyData 巷道边', () => {
|
|
|
+ test('生成矿井起止边/闭合边/传感器边/未分级扇出,且不再生成 mine 边', () => {
|
|
|
+ const org = [{ id: 'org1', departName: '某矿', parentId: null, isLeaf: true, fax: 'M1', depth: 0 }];
|
|
|
+ const areas = [
|
|
|
+ area('a1', 1),
|
|
|
+ area('a2', 1),
|
|
|
+ area('b1', 2, 100, { windrectId: 'w1' }),
|
|
|
+ area('b2', 2),
|
|
|
+ area('c1', 3),
|
|
|
+ area('d1', 4),
|
|
|
+ area('e1', 5),
|
|
|
+ area('u1', 0), // 未分级
|
|
|
+ ];
|
|
|
+ const windrects = [{ id: 'w1', mineCode: 'M1', deviceCode: 'DC1', devicePos: '主井口', status: 1 }];
|
|
|
+ const data = transformToTopologyData(org, areas, windrects);
|
|
|
+
|
|
|
+ expect(data.links.some((l) => l.kind === 'mine')).toBe(false);
|
|
|
+ // 矿井 → L1 进风起边
|
|
|
+ const startEdges = data.links.filter((l) => l.kind === 'roadway' && l.source === 'org:org1' && l.flow === 'intake');
|
|
|
+ expect(startEdges.map((l) => l.target).sort()).toEqual(['point:a1', 'point:a2']);
|
|
|
+ expect(startEdges.every((l) => l.pointId === l.target)).toBe(true);
|
|
|
+ // L5 → 矿井回风闭合边(无 pointId)
|
|
|
+ const closeEdges = data.links.filter((l) => l.kind === 'roadway' && l.target === 'org:org1' && l.flow === 'return');
|
|
|
+ expect(closeEdges.map((l) => l.source)).toEqual(['point:e1']);
|
|
|
+ expect(closeEdges.every((l) => !l.pointId)).toBe(true);
|
|
|
+ // 传感器边(布点绑定)
|
|
|
+ expect(data.links.some((l) => l.kind === 'sensor' && l.source === 'point:b1' && l.target === 'device:w1')).toBe(true);
|
|
|
+ // 未分级点位从 L3 扇出
|
|
|
+ expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:c1' && l.target === 'point:u1' && l.pointId === 'point:u1')).toBe(true);
|
|
|
+ // 节点去重与前缀
|
|
|
+ expect(data.nodes.filter((n) => n.id === 'point:a1')).toHaveLength(1);
|
|
|
+ expect(data.nodes.some((n) => n.id === 'org:org1')).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('formatAirVolumeLabel 风量标注', () => {
|
|
|
+ test('有风量标注 m³/min,无风量返回空串', () => {
|
|
|
+ expect(formatAirVolumeLabel(1200)).toBe('1200m³/min');
|
|
|
+ expect(formatAirVolumeLabel(0)).toBe('0m³/min');
|
|
|
+ expect(formatAirVolumeLabel(undefined)).toBe('');
|
|
|
+ expect(formatAirVolumeLabel(null as any)).toBe('');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('computeLayout 从左到右列式(线性)布局', () => {
|
|
|
+ // 列式布局几何参数:schematicTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
|
|
|
+ const centerY = (700 - 90) / 2; // 305
|
|
|
+ const MARGIN = 60;
|
|
|
+ const colGap = 300;
|
|
|
+
|
|
|
+ test('列式坐标:矿井最左,L1→L2→L3→L4→L5 各列从左到右 x 递增,同层同列', () => {
|
|
|
+ const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ expect(layout.mode).toBe('linear');
|
|
|
+ const pos = layout.positions;
|
|
|
+ // 矿井最左、垂直居中
|
|
|
+ expect(pos['org:m1'].x).toBe(MARGIN);
|
|
|
+ expect(pos['org:m1'].y).toBe(centerY);
|
|
|
+ // 各层级列 x 依次递增(列距 colGap)
|
|
|
+ expect(pos['point:a1'].x).toBe(MARGIN + colGap);
|
|
|
+ expect(pos['point:b1'].x).toBe(pos['point:a1'].x + colGap);
|
|
|
+ expect(pos['point:c1'].x).toBe(pos['point:b1'].x + colGap);
|
|
|
+ expect(pos['point:d1'].x).toBe(pos['point:c1'].x + colGap);
|
|
|
+ expect(pos['point:e1'].x).toBe(pos['point:d1'].x + colGap);
|
|
|
+ // 同层点位同列
|
|
|
+ expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('同层点位列内上下对称等距:间距 = min(该层级配置上限, 可用高度均分),以 centerY 居中', () => {
|
|
|
+ const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ const pos = layout.positions;
|
|
|
+ const cap = LAYOUT.columnCap[2] ?? LAYOUT.columnCapDefault;
|
|
|
+ const expected = Math.min(cap, 200); // maxSpread = min(200, centerY-40) = 200
|
|
|
+ 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('末端小点:所有点位可见(hiddenNodeIds 为空),无隐藏锚点', () => {
|
|
|
+ const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ expect(layout.hiddenNodeIds.size).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('数据布点定位:已绑定 → 点位正下方(node-side),未绑定 → 图下方', () => {
|
|
|
+ const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ const pos = layout.positions;
|
|
|
+ // w1 绑定 b1 → node-side:x 相同、y 向下偏移 LAYOUT.deviceOffset
|
|
|
+ expect(layout.devicePlacement['device:w1']).toBe('node-side');
|
|
|
+ expect(pos['device:w1'].x).toBe(pos['point:b1'].x);
|
|
|
+ expect(pos['device:w1'].y).toBe(pos['point:b1'].y + LAYOUT.deviceOffset);
|
|
|
+ // w2 未绑定 → bottom(图底一行:y = H - unboundRowGap)
|
|
|
+ expect(layout.devicePlacement['device:w2']).toBe('bottom');
|
|
|
+ expect(pos['device:w2'].y).toBe(700 - 30);
|
|
|
+ expect(pos['device:w2'].y).toBeGreaterThan(pos['point:e1'].y);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('未分级点位绘制在最后一列(L5 右侧一列)', () => {
|
|
|
+ const data = schematicTopo();
|
|
|
+ data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
|
|
|
+ data.links.push({ source: 'point:c1', target: 'point:u1', kind: 'roadway', flow: 'intake', pointId: 'point:u1' });
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ expect(layout.mode).toBe('linear');
|
|
|
+ expect(layout.positions['point:u1'].x).toBe(layout.positions['point:e1'].x + colGap);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('跨级边(无 L4 时 L3→L5)不影响列式布局', () => {
|
|
|
+ const data = schematicTopo();
|
|
|
+ data.nodes = data.nodes.filter((n) => n.id !== 'point:d1');
|
|
|
+ data.links = data.links.filter((l) => l.source !== 'point:d1' && l.target !== 'point:d1');
|
|
|
+ data.links.push({ source: 'point:c1', target: 'point:e1', kind: 'roadway', flow: 'return', pointId: 'point:e1' });
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ expect(layout.mode).toBe('linear');
|
|
|
+ expect(layout.hiddenNodeIds.size).toBe(0);
|
|
|
+ // L5 仍在固定层级列(L4 缺失不影响列位,位于 L3 右侧两列处)
|
|
|
+ expect(layout.positions['point:e1'].x).toBe(MARGIN + 5 * colGap);
|
|
|
+ expect(layout.positions['point:e1'].x).toBeGreaterThan(layout.positions['point:c1'].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('无 L1 时 L2 作为首列(矿井右侧第一列)', () => {
|
|
|
+ const data = schematicTopo();
|
|
|
+ data.nodes = data.nodes.filter((n) => n.id !== 'point:a1');
|
|
|
+ data.links = data.links.filter((l) => l.source !== 'point:a1' && l.target !== 'point:a1');
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ expect(layout.mode).toBe('linear');
|
|
|
+ expect(layout.hiddenNodeIds.size).toBe(0);
|
|
|
+ expect(layout.positions['org:m1'].x).toBeLessThan(layout.positions['point:b1'].x);
|
|
|
+ expect(layout.positions['point:b1'].x).toBeLessThan(layout.positions['point:c1'].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('无任何点位时仍正常定位矿井且不抛错', () => {
|
|
|
+ const layout = computeLayout({ nodes: [{ id: 'org:m1', name: '某矿', category: 0 }], links: [] }, 1200, 700);
|
|
|
+ expect(layout.mode).toBe('linear');
|
|
|
+ expect(layout.positions['org:m1']).toBeDefined();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('computeLayout 列内纵向间距按层级分别配置', () => {
|
|
|
+ // 保存被测试改写的间距配置,避免影响其他用例
|
|
|
+ const origCap1 = LAYOUT.columnCap[1];
|
|
|
+ const origCap2 = LAYOUT.columnCap[2];
|
|
|
+ const origCap3 = LAYOUT.columnCap[3];
|
|
|
+ afterEach(() => {
|
|
|
+ LAYOUT.columnCap[1] = origCap1;
|
|
|
+ LAYOUT.columnCap[2] = origCap2;
|
|
|
+ LAYOUT.columnCap[3] = origCap3;
|
|
|
+ });
|
|
|
+
|
|
|
+ test('L1/L3 配置不同间距上限时各列内间距分别生效,未配置层级回退默认', () => {
|
|
|
+ LAYOUT.columnCap[1] = 50;
|
|
|
+ LAYOUT.columnCap[3] = 130;
|
|
|
+ delete LAYOUT.columnCap[2]; // 未配置 → 回退 columnCapDefault
|
|
|
+ const data: TopologyData = {
|
|
|
+ nodes: [
|
|
|
+ { id: 'org:m1', name: '某矿', category: 0 },
|
|
|
+ { id: 'point:a1', name: 'L1a', category: 1, level: 1 },
|
|
|
+ { id: 'point:a2', name: 'L1b', category: 1, level: 1 },
|
|
|
+ { id: 'point:b1', name: 'L2a', category: 1, level: 2 },
|
|
|
+ { id: 'point:b2', name: 'L2b', category: 1, level: 2 },
|
|
|
+ { id: 'point:c1', name: 'L3a', category: 1, level: 3 },
|
|
|
+ { id: 'point:c2', name: 'L3b', category: 1, level: 3 },
|
|
|
+ { id: 'point:d1', name: 'L4a', category: 1, level: 4 },
|
|
|
+ { id: 'point:e1', name: 'L5a', category: 1, level: 5 },
|
|
|
+ ],
|
|
|
+ links: [],
|
|
|
+ } as unknown as TopologyData;
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ const pos = layout.positions;
|
|
|
+ const centerY = 700 / 2; // 无未绑定布点 → 底部无预留
|
|
|
+ // 各列 2 个点位且可用高度充足(maxSpread = min(200, centerY-40) = 200)→ 间距 = 配置值
|
|
|
+ expect(Math.abs(pos['point:a2'].y - pos['point:a1'].y)).toBe(50); // L1 cap=50
|
|
|
+ expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(Math.min(LAYOUT.columnCapDefault, 200)); // L2 回退默认
|
|
|
+ expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(130); // L3 cap=130
|
|
|
+ // 单点列(L4/L5)垂直居中
|
|
|
+ expect(pos['point:d1'].y).toBe(centerY);
|
|
|
+ expect(pos['point:e1'].y).toBe(centerY);
|
|
|
+ });
|
|
|
+});
|