|
|
@@ -1,6 +1,5 @@
|
|
|
import {
|
|
|
buildRelationArray,
|
|
|
- buildChains,
|
|
|
transformToTopologyData,
|
|
|
formatAirVolumeLabel,
|
|
|
LAYOUT,
|
|
|
@@ -29,138 +28,71 @@ const rel = (id: string, parentId: string, childId: string, extra: Record<string
|
|
|
...extra,
|
|
|
});
|
|
|
|
|
|
-/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系与 2 个布点,供布局测试) */
|
|
|
+/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系) */
|
|
|
function laneTopo(): TopologyData {
|
|
|
- const nodes = [
|
|
|
- { 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 },
|
|
|
- { 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: 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: 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;
|
|
|
+ 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 引用缺失的脏数据', () => {
|
|
|
+ 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('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 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('孤立点位(不在任何关系中)各自成为单节点链追加在最后', () => {
|
|
|
- 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('多根点按 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('纯环路数据(无根可达)兜底为单节点链,不丢点', () => {
|
|
|
- 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('生成地面/巷道点;每条巷道一条 parent→child 直线边;地面→lv1、lv5→地面', () => {
|
|
|
- const areas = [area('a1', 1), area('b1', 2, 90, { windrectId: 'w1' }), area('c1', 3), area('d1', 4), area('e1', 5)];
|
|
|
+ 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 windrects = [{ id: 'w1', mineCode: 'M1', devicePos: '主井口', status: 1 }];
|
|
|
- const data = transformToTopologyData(areas, buildRelationArray(areas, relations), windrects);
|
|
|
+ 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);
|
|
|
- // 无中间标记/锚点节点;每条巷道一条 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');
|
|
|
+ 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');
|
|
|
+ // 根边 地面→lv1、lv5→地面 均无 relationId
|
|
|
+ const rootIn = data.links.find((x) => x.source === ROOT_IN_ID && x.target === 'point:a1');
|
|
|
+ expect(rootIn?.relationId).toBeUndefined();
|
|
|
+ const rootOut = data.links.find((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID);
|
|
|
+ expect(rootOut?.relationId).toBeUndefined();
|
|
|
+ // 无数据布点节点
|
|
|
+ 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);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
@@ -173,14 +105,11 @@ describe('formatAirVolumeLabel 风量标注', () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-describe('computeLayout 巷道线模型布局', () => {
|
|
|
- // 几何参数:laneTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
|
|
|
- const centerY = (700 - 90) / 2; // 305
|
|
|
- const MARGIN = 60;
|
|
|
- const colGap = 300;
|
|
|
- const laneX = (idx: number) => MARGIN + colGap * idx;
|
|
|
+describe('computeLayout 列式布局', () => {
|
|
|
+ const centerY = 350; // 700 / 2
|
|
|
+ const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
|
|
|
|
|
|
- test('列序:地面 → lv1..lv5 → 地面,x 依次递增,同层同列', () => {
|
|
|
+ test('列序:总进 → lv1..lv5 → 总回,x 依次递增 colGap;总进/总回垂直居中', () => {
|
|
|
const layout = computeLayout(laneTopo(), 1200, 700);
|
|
|
const pos = layout.positions;
|
|
|
expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
|
|
|
@@ -189,113 +118,69 @@ describe('computeLayout 巷道线模型布局', () => {
|
|
|
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_OUT_ID].x).toBe(laneX(7)); // 未分级列之后为总回
|
|
|
expect(pos[ROOT_IN_ID].y).toBe(centerY);
|
|
|
expect(pos[ROOT_OUT_ID].y).toBe(centerY);
|
|
|
});
|
|
|
|
|
|
- test('数据布点:方向按锚点层级(level<3 左侧 / =3 正下方 / >3 右侧),未绑定 → 图下方', () => {
|
|
|
- const layout = computeLayout(laneTopo(), 1200, 700);
|
|
|
- const pos = layout.positions;
|
|
|
- // 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('数据布点:绑定 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('数据布点: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;
|
|
|
- // 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('同级父子关系:子节点独立成列(整列距右移),后续列顺延,连线统一等长', () => {
|
|
|
+ test('同层级同列,行距 = min(rowGap, 可用高度均分),围绕中心对称', () => {
|
|
|
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;
|
|
|
- // 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('同层点位列内上下对称等距:间距 = 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
|
|
|
+ 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 = Math.min(LAYOUT.rowGap, 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('未分级点位在 lv5 与地面之间一列', () => {
|
|
|
- const data = laneTopo();
|
|
|
- data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
|
|
|
+ test('colGap/rowGap 可配置生效', () => {
|
|
|
+ const origCol = LAYOUT.colGap;
|
|
|
+ const origRow = LAYOUT.rowGap;
|
|
|
+ try {
|
|
|
+ LAYOUT.colGap = 200;
|
|
|
+ LAYOUT.rowGap = 60;
|
|
|
+ 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:a1'].x).toBe(LAYOUT.margin + 200);
|
|
|
+ expect(pos['point:b1'].x).toBe(LAYOUT.margin + 2 * 200);
|
|
|
+ expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(60);
|
|
|
+ } finally {
|
|
|
+ LAYOUT.colGap = origCol;
|
|
|
+ LAYOUT.rowGap = origRow;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ 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);
|
|
|
- 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('无任何点位时仍正常定位地面节点且不抛错', () => {
|
|
|
+ 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);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {
|
|
|
const layout = computeLayout(
|
|
|
{
|
|
|
nodes: [
|
|
|
@@ -305,186 +190,11 @@ describe('computeLayout 巷道线模型布局', () => {
|
|
|
links: [],
|
|
|
},
|
|
|
1200,
|
|
|
- 700
|
|
|
+ 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('关系列表倒序返回时,两条 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 origCap2 = LAYOUT.columnCap[2];
|
|
|
- afterEach(() => {
|
|
|
- LAYOUT.columnCap[2] = origCap2;
|
|
|
- });
|
|
|
-
|
|
|
- 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);
|
|
|
- // 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]);
|
|
|
+ expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
|
|
|
+ expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
|
|
|
});
|
|
|
});
|