|
|
@@ -1,9 +1,11 @@
|
|
|
import {
|
|
|
- buildVentilationPaths,
|
|
|
- extractDistrictKeys,
|
|
|
- formatAirVolumeLabel,
|
|
|
+ buildRelationArray,
|
|
|
+ buildChains,
|
|
|
transformToTopologyData,
|
|
|
+ formatAirVolumeLabel,
|
|
|
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';
|
|
|
@@ -18,10 +20,20 @@ const area = (id: string, level: number, airVolume = 100, extra: Record<string,
|
|
|
...extra,
|
|
|
});
|
|
|
|
|
|
-/** 构造一个完整的通风回路拓扑(单行示意图数据) */
|
|
|
-function schematicTopo(): TopologyData {
|
|
|
+/** 构造巷道关系(MineAreaRelation) */
|
|
|
+const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
|
|
|
+ id,
|
|
|
+ mineCode: 'M1',
|
|
|
+ parentId,
|
|
|
+ childId,
|
|
|
+ ...extra,
|
|
|
+});
|
|
|
+
|
|
|
+/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系与 2 个布点,供布局测试) */
|
|
|
+function laneTopo(): TopologyData {
|
|
|
const nodes = [
|
|
|
- { id: 'org:m1', name: '某矿', category: 0 },
|
|
|
+ { id: ROOT_IN_ID, name: '地面', category: 0 },
|
|
|
+ { id: ROOT_OUT_ID, 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 },
|
|
|
@@ -32,189 +44,123 @@ function schematicTopo(): TopologyData {
|
|
|
{ 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: ROOT_IN_ID, 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:e1', target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: 'point:e1' },
|
|
|
+ // w1 绑定 b1(巷道 a1→b1 的子节点)→ sensor 源为 point:b1,布点画在 a1-b1 连线中点
|
|
|
{ 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('buildRelationArray 关系数组生成', () => {
|
|
|
+ test('过滤 parent/child 引用缺失的脏数据', () => {
|
|
|
+ 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].parent.id).toBe('1');
|
|
|
+ expect(arr[0].child.id).toBe('2');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-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']);
|
|
|
+describe('buildChains 链式结构构建', () => {
|
|
|
+ const chainPoint = (id: string, level: number, airVolume = 100) => ({
|
|
|
+ id,
|
|
|
+ name: id,
|
|
|
+ level,
|
|
|
+ airVolume,
|
|
|
+ mineCode: 'M1',
|
|
|
});
|
|
|
+ const chainRel = (parent: any, child: any) => ({ parent, child });
|
|
|
|
|
|
- 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('分支拆成独立链:共享前缀只保留在第一条链,父链在前、分支链在后', () => {
|
|
|
+ const a1 = chainPoint('a1', 1, 100);
|
|
|
+ const b1 = chainPoint('b1', 2, 60);
|
|
|
+ const b2 = chainPoint('b2', 2, 90);
|
|
|
+ const c1 = chainPoint('c1', 3, 40);
|
|
|
+ const c2 = chainPoint('c2', 3, 80);
|
|
|
+ const d1 = chainPoint('d1', 4, 30);
|
|
|
+ const e1 = chainPoint('e1', 5, 20);
|
|
|
+ const chains = buildChains([
|
|
|
+ chainRel(a1, b1),
|
|
|
+ chainRel(a1, b2),
|
|
|
+ chainRel(b1, c1),
|
|
|
+ chainRel(b2, c2),
|
|
|
+ chainRel(c1, d1),
|
|
|
+ chainRel(d1, e1),
|
|
|
+ ]);
|
|
|
+ expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
|
|
|
+ ['a1', 'b1', 'c1', 'd1', 'e1'],
|
|
|
+ ['b2', 'c2'],
|
|
|
+ ]);
|
|
|
});
|
|
|
|
|
|
- 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 a1 = chainPoint('a1', 1, 100);
|
|
|
+ const b1 = chainPoint('b1', 2, 90);
|
|
|
+ const solo = chainPoint('solo', 3, 10);
|
|
|
+ const chains = buildChains([chainRel(a1, b1)], [a1, b1, solo]);
|
|
|
+ expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
|
|
|
+ ['a1', 'b1'],
|
|
|
+ ['solo'],
|
|
|
+ ]);
|
|
|
});
|
|
|
|
|
|
- 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('多根点按 level 升序、风量降序稳定排序,各自成链;脏关系不报错', () => {
|
|
|
+ const a1 = chainPoint('a1', 1, 100);
|
|
|
+ const a2 = chainPoint('a2', 1, 90);
|
|
|
+ const a3 = chainPoint('a3', 2, 200);
|
|
|
+ const b1 = chainPoint('b1', 2, 80);
|
|
|
+ const b2 = chainPoint('b2', 2, 70);
|
|
|
+ const c3 = chainPoint('c3', 3, 60);
|
|
|
+ const chains = buildChains([
|
|
|
+ chainRel(a1, b1),
|
|
|
+ chainRel(a2, b2),
|
|
|
+ chainRel(a3, c3),
|
|
|
+ chainRel(a2, undefined), // 脏关系:child 缺失,被过滤
|
|
|
+ ]);
|
|
|
+ expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
|
|
|
+ ['a1', 'b1'],
|
|
|
+ ['a2', 'b2'],
|
|
|
+ ['a3', 'c3'],
|
|
|
+ ]);
|
|
|
});
|
|
|
|
|
|
- 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);
|
|
|
- }
|
|
|
+ test('纯环路数据(无根可达)兜底为单节点链,不丢点', () => {
|
|
|
+ const a = chainPoint('a', 1, 100);
|
|
|
+ const b = chainPoint('b', 2, 90);
|
|
|
+ const chains = buildChains([chainRel(a, b), chainRel(b, a)]);
|
|
|
+ expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([['a'], ['b']]);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-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('transformToTopologyData 巷道线模型', () => {
|
|
|
+ test('生成地面/巷道点;每条巷道一条 parent→child 直线边;地面→lv1、lv5→地面', () => {
|
|
|
+ const areas = [area('a1', 1), area('b1', 2, 90, { windrectId: 'w1' }), area('c1', 3), area('d1', 4), area('e1', 5)];
|
|
|
+ const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')];
|
|
|
+ const windrects = [{ id: 'w1', mineCode: 'M1', devicePos: '主井口', status: 1 }];
|
|
|
+ const data = transformToTopologyData(areas, buildRelationArray(areas, relations), windrects);
|
|
|
+
|
|
|
+ // 地面节点
|
|
|
+ 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);
|
|
|
+ // 无中间标记/锚点节点;每条巷道一条 parent → child 直线边(pointId 指向子巷道点)
|
|
|
+ expect(data.nodes.filter((n) => n.category === 3)).toHaveLength(0);
|
|
|
+ expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:a1' && l.target === 'point:b1' && l.pointId === 'point:b1')).toBe(true);
|
|
|
+ // 地面 → lv1、lv5 → 地面
|
|
|
+ expect(data.links.some((l) => l.kind === 'roadway' && l.source === ROOT_IN_ID && l.target === 'point:a1')).toBe(true);
|
|
|
+ expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:e1' && l.target === ROOT_OUT_ID)).toBe(true);
|
|
|
+ // sensor 源为子巷道点(b1)
|
|
|
+ const sensor = data.links.find((l) => l.kind === 'sensor' && l.target === 'device:w1');
|
|
|
+ expect(sensor?.source).toBe('point:b1');
|
|
|
+ // 巷道流向:子节点 level ≤3 进风,>3 回风
|
|
|
+ expect(data.links.find((l) => l.source === 'point:c1' && l.target === 'point:d1')?.flow).toBe('return');
|
|
|
+ expect(data.links.find((l) => l.source === 'point:a1' && l.target === 'point:b1')?.flow).toBe('intake');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
@@ -227,134 +173,318 @@ describe('formatAirVolumeLabel 风量标注', () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-describe('computeLayout 从左到右列式(线性)布局', () => {
|
|
|
- // 列式布局几何参数:schematicTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
|
|
|
+describe('computeLayout 巷道线模型布局', () => {
|
|
|
+ // 几何参数:laneTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
|
|
|
const centerY = (700 - 90) / 2; // 305
|
|
|
const MARGIN = 60;
|
|
|
const colGap = 300;
|
|
|
+ const laneX = (idx: number) => MARGIN + colGap * idx;
|
|
|
|
|
|
- test('列式坐标:矿井最左,L1→L2→L3→L4→L5 各列从左到右 x 递增,同层同列', () => {
|
|
|
- const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
- expect(layout.mode).toBe('linear');
|
|
|
+ test('列序:地面 → lv1..lv5 → 地面,x 依次递增,同层同列', () => {
|
|
|
+ const layout = computeLayout(laneTopo(), 1200, 700);
|
|
|
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);
|
|
|
+ expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
|
|
|
+ expect(pos['point:a1'].x).toBe(laneX(1));
|
|
|
+ expect(pos['point:b1'].x).toBe(laneX(2));
|
|
|
+ expect(pos['point:c1'].x).toBe(laneX(3));
|
|
|
+ expect(pos['point:d1'].x).toBe(laneX(4));
|
|
|
+ expect(pos['point:e1'].x).toBe(laneX(5));
|
|
|
+ expect(pos[ROOT_OUT_ID].x).toBe(laneX(7));
|
|
|
+ expect(pos['point:b2'].x).toBe(pos['point:b1'].x); // 同层同列
|
|
|
+ // 地面节点垂直居中
|
|
|
+ expect(pos[ROOT_IN_ID].y).toBe(centerY);
|
|
|
+ expect(pos[ROOT_OUT_ID].y).toBe(centerY);
|
|
|
});
|
|
|
|
|
|
- test('同层点位列内上下对称等距:间距 = min(该层级配置上限, 可用高度均分),以 centerY 居中', () => {
|
|
|
- const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ test('数据布点:方向按锚点层级(level<3 左侧 / =3 正下方 / >3 右侧),未绑定 → 图下方', () => {
|
|
|
+ const layout = computeLayout(laneTopo(), 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);
|
|
|
+ // w1 绑定 b1(lv2 < 3)→ 点位左侧
|
|
|
+ expect(layout.devicePlacement['device:w1']).toBe('left');
|
|
|
+ expect(pos['device:w1'].x).toBe(pos['point:b1'].x - LAYOUT.deviceOffset[2]);
|
|
|
+ expect(pos['device:w1'].y).toBe(pos['point:b1'].y);
|
|
|
+ // w2 未绑定 → bottom(图底一行:y = H - unboundRowGap)
|
|
|
+ expect(layout.devicePlacement['device:w2']).toBe('bottom');
|
|
|
+ expect(pos['device:w2'].y).toBe(700 - 30);
|
|
|
});
|
|
|
|
|
|
- test('末端小点:所有点位可见(hiddenNodeIds 为空),无隐藏锚点', () => {
|
|
|
- const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
- expect(layout.hiddenNodeIds.size).toBe(0);
|
|
|
+ test('数据布点:绑定 lv1 锚点的布点位于点位左侧(level<3 向左)', () => {
|
|
|
+ const data = laneTopo();
|
|
|
+ data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
|
|
|
+ data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ const pos = layout.positions;
|
|
|
+ expect(layout.devicePlacement['device:w3']).toBe('left');
|
|
|
+ expect(pos['device:w3'].x).toBe(pos['point:a1'].x - LAYOUT.deviceOffset[1]);
|
|
|
+ expect(pos['device:w3'].y).toBe(pos['point:a1'].y);
|
|
|
});
|
|
|
|
|
|
- test('数据布点定位:已绑定 → 点位正下方(node-side),未绑定 → 图下方', () => {
|
|
|
- const layout = computeLayout(schematicTopo(), 1200, 700);
|
|
|
+ test('数据布点:level=3 在锚点正下方、level>3 在锚点右侧、未分级/level=0 回退正下方', () => {
|
|
|
+ const data = laneTopo();
|
|
|
+ // c1(lv3)、d1(lv4)、e1(lv5) 各绑一个布点,另加未分级与 level=0 点位绑定布点
|
|
|
+ data.nodes.push(
|
|
|
+ { id: 'device:w3', name: '传感器3', category: 2, status: 'normal' },
|
|
|
+ { id: 'device:w4', name: '传感器4', category: 2, status: 'normal' },
|
|
|
+ { id: 'device:w5', name: '传感器5', category: 2, status: 'normal' },
|
|
|
+ { id: 'point:u1', name: '未分级', category: 1 },
|
|
|
+ { id: 'device:w6', name: '传感器6', category: 2, status: 'normal' },
|
|
|
+ { id: 'point:u0', name: '零级', category: 1, level: 0 },
|
|
|
+ { id: 'device:w7', name: '传感器7', category: 2, status: 'normal' }
|
|
|
+ );
|
|
|
+ data.links.push(
|
|
|
+ { source: 'point:c1', target: 'device:w3', kind: 'sensor' },
|
|
|
+ { source: 'point:d1', target: 'device:w4', kind: 'sensor' },
|
|
|
+ { source: 'point:e1', target: 'device:w5', kind: 'sensor' },
|
|
|
+ { source: 'point:u1', target: 'device:w6', kind: 'sensor' },
|
|
|
+ { source: 'point:u0', target: 'device:w7', kind: 'sensor' }
|
|
|
+ );
|
|
|
+ const layout = computeLayout(data, 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);
|
|
|
+ // lv3 → 正下方
|
|
|
+ expect(layout.devicePlacement['device:w3']).toBe('down');
|
|
|
+ expect(pos['device:w3'].x).toBe(pos['point:c1'].x);
|
|
|
+ expect(pos['device:w3'].y).toBe(pos['point:c1'].y + LAYOUT.deviceOffset[3]);
|
|
|
+ // lv4 / lv5 → 右侧
|
|
|
+ expect(layout.devicePlacement['device:w4']).toBe('right');
|
|
|
+ expect(pos['device:w4'].x).toBe(pos['point:d1'].x + LAYOUT.deviceOffset[4]);
|
|
|
+ expect(pos['device:w4'].y).toBe(pos['point:d1'].y);
|
|
|
+ expect(layout.devicePlacement['device:w5']).toBe('right');
|
|
|
+ expect(pos['device:w5'].x).toBe(pos['point:e1'].x + LAYOUT.deviceOffset[5]);
|
|
|
+ expect(pos['device:w5'].y).toBe(pos['point:e1'].y);
|
|
|
+ // 未分级(无 level)→ 回退正下方
|
|
|
+ expect(layout.devicePlacement['device:w6']).toBe('down');
|
|
|
+ expect(pos['device:w6'].x).toBe(pos['point:u1'].x);
|
|
|
+ expect(pos['device:w6'].y).toBe(pos['point:u1'].y + LAYOUT.deviceOffset.ungraded);
|
|
|
+ // level=0(不在 1..5 范围内)→ 同样回退正下方
|
|
|
+ expect(layout.devicePlacement['device:w7']).toBe('down');
|
|
|
+ expect(pos['device:w7'].x).toBe(pos['point:u0'].x);
|
|
|
+ expect(pos['device:w7'].y).toBe(pos['point:u0'].y + LAYOUT.deviceOffset.ungraded);
|
|
|
});
|
|
|
|
|
|
- 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' });
|
|
|
+ test('同级父子关系:子节点独立成列(整列距右移),后续列顺延,连线统一等长', () => {
|
|
|
+ const data = laneTopo();
|
|
|
+ data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
|
|
|
const layout = computeLayout(data, 1200, 700);
|
|
|
- expect(layout.mode).toBe('linear');
|
|
|
- expect(layout.positions['point:u1'].x).toBe(layout.positions['point:e1'].x + colGap);
|
|
|
+ const pos = layout.positions;
|
|
|
+ // b2 独立成列:位于 b1(lv2 列)右侧一整列距,连线与 lv1→lv2 等长
|
|
|
+ expect(pos['point:b2'].x).toBe(pos['point:b1'].x + colGap);
|
|
|
+ // 后续列(lv3/lv4/lv5/地面)顺延右移一列
|
|
|
+ expect(pos['point:c1'].x).toBe(MARGIN + 4 * colGap);
|
|
|
+ expect(pos['point:d1'].x).toBe(MARGIN + 5 * colGap);
|
|
|
+ expect(pos[ROOT_OUT_ID].x).toBe(MARGIN + 8 * colGap);
|
|
|
+ // 独立成列后各自垂直居中,y 对齐
|
|
|
+ expect(pos['point:b1'].y).toBe(centerY);
|
|
|
+ expect(pos['point:b2'].y).toBe(centerY);
|
|
|
});
|
|
|
|
|
|
- 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('同层点位列内上下对称等距:间距 = min(该层级 columnCap, 可用高度均分),以 centerY 居中', () => {
|
|
|
+ const layout = computeLayout(laneTopo(), 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('无 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');
|
|
|
+ test('未分级点位在 lv5 与地面之间一列', () => {
|
|
|
+ const data = laneTopo();
|
|
|
+ data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
|
|
|
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);
|
|
|
+ expect(layout.positions['point:u1'].x).toBe(laneX(6));
|
|
|
+ expect(layout.positions['point:u1'].x).toBeGreaterThan(layout.positions['point:e1'].x);
|
|
|
+ expect(layout.positions['point:u1'].x).toBeLessThan(layout.positions[ROOT_OUT_ID].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ 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();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('computeLayout 链式顺序布局', () => {
|
|
|
+ test('提供链式结构时各层列内按 (链下标, 链内位置) 排序:链间上下并行、同链路径顺序', () => {
|
|
|
+ // 两条链共享前缀 a1:a1→b1→c1→d1→e1(链0)与 a1→b2→c2→d2→e2(链1);
|
|
|
+ // 链1 节点风量故意更高:若未按链排序(回退风量降序)b2 会排在 b1 上方
|
|
|
+ const areas = [
|
|
|
+ area('a1', 1, 100),
|
|
|
+ area('b1', 2, 60),
|
|
|
+ area('b2', 2, 90),
|
|
|
+ area('c1', 3, 40),
|
|
|
+ area('c2', 3, 80),
|
|
|
+ area('d1', 4, 30),
|
|
|
+ area('d2', 4, 70),
|
|
|
+ area('e1', 5, 20),
|
|
|
+ area('e2', 5, 60),
|
|
|
+ ];
|
|
|
+ const relations = [
|
|
|
+ rel('r1', 'a1', 'b1'),
|
|
|
+ rel('r2', 'a1', 'b2'),
|
|
|
+ rel('r3', 'b1', 'c1'),
|
|
|
+ rel('r4', 'b2', 'c2'),
|
|
|
+ rel('r5', 'c1', 'd1'),
|
|
|
+ rel('r6', 'c2', 'd2'),
|
|
|
+ rel('r7', 'd1', 'e1'),
|
|
|
+ rel('r8', 'd2', 'e2'),
|
|
|
+ ];
|
|
|
+ const relationArr = buildRelationArray(areas, relations);
|
|
|
+ const chains = buildChains(relationArr);
|
|
|
+ const data = transformToTopologyData(areas, relationArr, []);
|
|
|
+ const layout = computeLayout(data, 1200, 700, chains);
|
|
|
+ const pos = layout.positions;
|
|
|
+ // 链间上下并行:链0(b1/c1/d1)在链1(b2/c2/d2)上方
|
|
|
+ expect(pos['point:b1'].y).toBeLessThan(pos['point:b2'].y);
|
|
|
+ expect(pos['point:c1'].y).toBeLessThan(pos['point:c2'].y);
|
|
|
+ expect(pos['point:d1'].y).toBeLessThan(pos['point:d2'].y);
|
|
|
+ // 同链路径顺序:链0 各层节点均为列内首位,y 一致(每列同序、间距相同)
|
|
|
+ expect(pos['point:b1'].y).toBe(pos['point:c1'].y);
|
|
|
+ expect(pos['point:c1'].y).toBe(pos['point:d1'].y);
|
|
|
+ expect(pos['point:d1'].y).toBe(pos['point:e1'].y);
|
|
|
+ // 对比:不传链式结构时回退风量降序,风量更高的链1 在上(证明链排序生效)
|
|
|
+ const fallback = computeLayout(data, 1200, 700);
|
|
|
+ expect(fallback.positions['point:b2'].y).toBeLessThan(fallback.positions['point:b1'].y);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('computeLayout 同级连线平行(getMineAreaRelation 真实数据)', () => {
|
|
|
+ /** 真实返回:9 个测风点位(id=1..9)+ 9 条巷道关系(id=5/6 为两条 lv3 同级连线 4→6、5→7) */
|
|
|
+ const realAreas = () => [
|
|
|
+ area('1', 1, 5125, { windrectId: '1' }),
|
|
|
+ area('2', 2, 456, { windrectId: '2' }),
|
|
|
+ area('3', 2, 568, { windrectId: '3' }),
|
|
|
+ area('4', 3, 458, { windrectId: null }),
|
|
|
+ area('5', 3, 895, { windrectId: '5' }),
|
|
|
+ area('6', 3, 425, { windrectId: '6' }),
|
|
|
+ area('7', 3, 1246, { windrectId: '7' }),
|
|
|
+ area('8', 4, 2895, { windrectId: '8' }),
|
|
|
+ area('9', 5, 5725, { windrectId: '9' }),
|
|
|
+ ];
|
|
|
+ const realRels = () => [
|
|
|
+ rel('1', '1', '2'),
|
|
|
+ rel('2', '1', '3'),
|
|
|
+ rel('3', '2', '4'),
|
|
|
+ rel('4', '3', '5'),
|
|
|
+ rel('5', '4', '6'),
|
|
|
+ rel('6', '5', '7'),
|
|
|
+ rel('7', '6', '8'),
|
|
|
+ rel('8', '7', '8'),
|
|
|
+ rel('9', '8', '9'),
|
|
|
+ ];
|
|
|
+ const laneX = (idx: number) => 60 + 300 * idx;
|
|
|
+ const layoutOf = (rels: ReturnType<typeof realRels>) => {
|
|
|
+ const relationArr = buildRelationArray(realAreas(), rels);
|
|
|
+ const chains = buildChains(relationArr);
|
|
|
+ return computeLayout(transformToTopologyData(realAreas(), relationArr, []), 1200, 700, chains);
|
|
|
+ };
|
|
|
+
|
|
|
+ test('列序快照 + 两条 lv3 同级连线(4→6、5→7)平行:子节点共列、y 镜像父节点', () => {
|
|
|
+ const pos = layoutOf(realRels()).positions;
|
|
|
+ // 列序:地面 → lv1 → lv2 → lv3(父) → lv3(子) → lv4 → lv5 → 地面
|
|
|
+ expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
|
|
|
+ expect(pos['point:1'].x).toBe(laneX(1));
|
|
|
+ expect(pos['point:2'].x).toBe(laneX(2));
|
|
|
+ expect(pos['point:3'].x).toBe(laneX(2));
|
|
|
+ expect(pos['point:4'].x).toBe(laneX(3));
|
|
|
+ expect(pos['point:5'].x).toBe(laneX(3));
|
|
|
+ expect(pos['point:6'].x).toBe(laneX(4));
|
|
|
+ expect(pos['point:7'].x).toBe(laneX(4));
|
|
|
+ expect(pos['point:8'].x).toBe(laneX(5));
|
|
|
+ expect(pos['point:9'].x).toBe(laneX(6));
|
|
|
+ expect(pos[ROOT_OUT_ID].x).toBe(laneX(8));
|
|
|
+ // 平行:子节点 6、7 共列(同一 x),且纵向镜像父节点顺序 → 连线 4→6、5→7 水平平行、等长
|
|
|
+ expect(pos['point:4'].x).toBe(pos['point:5'].x);
|
|
|
+ expect(pos['point:6'].x).toBe(pos['point:7'].x);
|
|
|
+ expect(pos['point:6'].y).toBe(pos['point:4'].y);
|
|
|
+ expect(pos['point:7'].y).toBe(pos['point:5'].y);
|
|
|
});
|
|
|
|
|
|
- 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();
|
|
|
+ test('关系列表倒序返回时,两条 lv3 同级连线仍平行(不依赖 API 顺序)', () => {
|
|
|
+ const pos = layoutOf(realRels().reverse()).positions;
|
|
|
+ // 平行保证:子节点共列、y 镜像父节点 → 连线 4→6、5→7 水平平行、配对不变、互不交叉
|
|
|
+ expect(pos['point:6'].x).toBe(pos['point:7'].x);
|
|
|
+ expect(pos['point:6'].y).toBe(pos['point:4'].y);
|
|
|
+ expect(pos['point:7'].y).toBe(pos['point:5'].y);
|
|
|
+ // 注:buildChains 按关系首次出现顺序选主链,倒序时链0/链1 的上下槽位可能互换,
|
|
|
+ // 但平行性与父子配对关系不变(API 按 createTime 稳定返回时为确定布局)
|
|
|
});
|
|
|
});
|
|
|
|
|
|
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;
|
|
|
+ test('配置不同间距上限时列内间距分别生效', () => {
|
|
|
+ LAYOUT.columnCap[2] = 50;
|
|
|
+ const layout = computeLayout(laneTopo(), 1200, 700);
|
|
|
+ expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(50);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('computeLayout LAYOUT 按 level 配置生效', () => {
|
|
|
+ // 保存原始配置,用例结束后恢复,避免影响其他用例
|
|
|
+ const orig = {
|
|
|
+ minEdge: { ...LAYOUT.minEdgeLength },
|
|
|
+ minGap: { ...LAYOUT.columnMinGap },
|
|
|
+ placement: { ...LAYOUT.devicePlacement },
|
|
|
+ offset: { ...LAYOUT.deviceOffset },
|
|
|
+ };
|
|
|
+ afterEach(() => {
|
|
|
+ Object.assign(LAYOUT.minEdgeLength, orig.minEdge);
|
|
|
+ Object.assign(LAYOUT.columnMinGap, orig.minGap);
|
|
|
+ Object.assign(LAYOUT.devicePlacement, orig.placement);
|
|
|
+ Object.assign(LAYOUT.deviceOffset, orig.offset);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('minEdgeLength 按 level 生效:同级子列水平偏移 = 父列 x + max(sameLevelOffset, minEdgeLength[lv])', () => {
|
|
|
+ LAYOUT.minEdgeLength[2] = 500;
|
|
|
+ const data = laneTopo();
|
|
|
+ data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
|
|
|
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);
|
|
|
+ // b2(lv2 同级子列)x = b1.x + max(200, 500) = b1.x + 500
|
|
|
+ expect(layout.positions['point:b2'].x).toBe(layout.positions['point:b1'].x + 500);
|
|
|
+ // 后续列仍按 laneX 顺延(c1 为 lv3 列,索引 +1)
|
|
|
+ expect(layout.positions['point:c1'].x).toBe(60 + 4 * 300);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('columnMinGap 按 level 生效:列内纵向间距不低于下限', () => {
|
|
|
+ LAYOUT.columnMinGap[2] = 400;
|
|
|
+ // laneTopo 的 lv2 列(b1、b2)可用均分 = 200 < 400 → 间距被下限钳制为 400
|
|
|
+ const layout = computeLayout(laneTopo(), 1200, 700);
|
|
|
+ expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(400);
|
|
|
+ // 未配置的层级(lv3 单节点列)不受影响
|
|
|
+ expect(layout.positions['point:c1'].y).toBe((700 - 90) / 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('devicePlacement/deviceOffset 按 level 生效:改 lv3 布点方向与距离', () => {
|
|
|
+ LAYOUT.devicePlacement[3] = 'left';
|
|
|
+ LAYOUT.deviceOffset[3] = 60;
|
|
|
+ const data = laneTopo();
|
|
|
+ data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
|
|
|
+ data.links.push({ source: 'point:c1', target: 'device:w3', kind: 'sensor' });
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ expect(layout.devicePlacement['device:w3']).toBe('left');
|
|
|
+ expect(layout.positions['device:w3'].x).toBe(layout.positions['point:c1'].x - 60);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('devicePlacement 按 level 生效:改 lv1 布点方向为右侧', () => {
|
|
|
+ LAYOUT.devicePlacement[1] = 'right';
|
|
|
+ const data = laneTopo();
|
|
|
+ data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
|
|
|
+ data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
|
|
|
+ const layout = computeLayout(data, 1200, 700);
|
|
|
+ expect(layout.devicePlacement['device:w3']).toBe('right');
|
|
|
+ expect(layout.positions['device:w3'].x).toBe(layout.positions['point:a1'].x + LAYOUT.deviceOffset[1]);
|
|
|
});
|
|
|
});
|