chartflow 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. chartflow/__init__.py +28 -0
  2. chartflow/_verify_importexport.py +169 -0
  3. chartflow/edit/__init__.py +119 -0
  4. chartflow/edit/code_editor.py +1007 -0
  5. chartflow/edit/code_folder.py +523 -0
  6. chartflow/edit/completer.py +2652 -0
  7. chartflow/edit/context_analyzer.py +521 -0
  8. chartflow/edit/demo.py +469 -0
  9. chartflow/edit/line_number_area.py +548 -0
  10. chartflow/edit/minimap.py +581 -0
  11. chartflow/edit/python_editor.py +509 -0
  12. chartflow/edit/syntax_highlighter.py +291 -0
  13. chartflow/edit/test_editor.py +190 -0
  14. chartflow/flow/__init__.py +31 -0
  15. chartflow/flow/_demo.py +98 -0
  16. chartflow/flow/chart.py +1101 -0
  17. chartflow/flow/editor.py +375 -0
  18. chartflow/flow/flow.py +6 -0
  19. chartflow/flow/qchart.py +1250 -0
  20. chartflow/flow/test/__init__.py +0 -0
  21. chartflow/flow/test/test_chart.py +537 -0
  22. chartflow/flow/test_decorator_sync.py +102 -0
  23. chartflow/fsm/__init__.py +84 -0
  24. chartflow/fsm/decorators.py +395 -0
  25. chartflow/fsm/demo.py +381 -0
  26. chartflow/fsm/demo_pyqt6.py +881 -0
  27. chartflow/fsm/demo_tree.py +46 -0
  28. chartflow/fsm/logic.py +447 -0
  29. chartflow/fsm/meta.py +569 -0
  30. chartflow/fsm/scheduler.py +427 -0
  31. chartflow/fsm/stage.py +2079 -0
  32. chartflow/fsm/stdio.py +66 -0
  33. chartflow/fsm/test/__init__.py +7 -0
  34. chartflow/fsm/test/test_decorators.py +210 -0
  35. chartflow/fsm/test/test_events.py +202 -0
  36. chartflow/fsm/test/test_integration.py +596 -0
  37. chartflow/fsm/test/test_logic.py +371 -0
  38. chartflow/fsm/test/test_meta.py +192 -0
  39. chartflow/fsm/test/test_runtime_param.py +336 -0
  40. chartflow/fsm/test/test_scheduler.py +280 -0
  41. chartflow/fsm/test/test_stage.py +314 -0
  42. chartflow/fsm/test_behavior_events.py +263 -0
  43. chartflow/fsm/test_nested_parallel.py +80 -0
  44. chartflow/fsm/test_stage_spec.py +448 -0
  45. chartflow/fsm/utils.py +34 -0
  46. chartflow/node/__init__.py +66 -0
  47. chartflow/node/_node_base.py +727 -0
  48. chartflow/node/_node_interaction.py +488 -0
  49. chartflow/node/_node_interface.py +426 -0
  50. chartflow/node/_node_markers.py +648 -0
  51. chartflow/node/_node_ports.py +536 -0
  52. chartflow/node/_port_interface.py +111 -0
  53. chartflow/node/arrow_item.py +287 -0
  54. chartflow/node/color_utils.py +113 -0
  55. chartflow/node/connection_item.py +939 -0
  56. chartflow/node/demo.py +258 -0
  57. chartflow/node/demo_arrow_drag.py +46 -0
  58. chartflow/node/demo_decorator.py +63 -0
  59. chartflow/node/demo_ports.py +69 -0
  60. chartflow/node/enums.py +35 -0
  61. chartflow/node/example.py +84 -0
  62. chartflow/node/layout_utils.py +307 -0
  63. chartflow/node/main_window.py +196 -0
  64. chartflow/node/menu_bar.py +240 -0
  65. chartflow/node/node_canvas.py +1730 -0
  66. chartflow/node/node_item.py +754 -0
  67. chartflow/node/popmenu.py +472 -0
  68. chartflow/node/port_item.py +591 -0
  69. chartflow/node/qnode_editor.py +73 -0
  70. chartflow/node/selection_rect.py +53 -0
  71. chartflow/node/style_panel.py +615 -0
  72. chartflow/node/styles.py +63 -0
  73. chartflow/node/test_qnode.py +2336 -0
  74. chartflow/showcase.py +528 -0
  75. chartflow/theme.py +508 -0
  76. chartflow-0.1.dist-info/METADATA +23 -0
  77. chartflow-0.1.dist-info/RECORD +79 -0
  78. chartflow-0.1.dist-info/WHEEL +5 -0
  79. chartflow-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2336 @@
1
+ """
2
+ qnode库测试模块
3
+
4
+ 使用PyQt6的测试框架测试所有功能
5
+ """
6
+
7
+ from PyQt6.QtGui import QColor
8
+ from PyQt6.QtWidgets import QApplication
9
+ from PyQt6.QtCore import QPoint
10
+
11
+
12
+ import sys
13
+ import unittest
14
+ from typing import Optional
15
+
16
+ from PyQt6.QtWidgets import QApplication, QGraphicsScene
17
+ from PyQt6.QtCore import Qt, QPointF, QRectF
18
+ from PyQt6.QtTest import QTest
19
+
20
+ from chartflow.node import (
21
+ QNodeCanvas, QNodeItem, ConnectionItem, ArrowItem,
22
+ NodeState, NodeShape, ArrowState, ConnectionType,
23
+ NodeStyle, ConnectionStyle, ArrowStyle,
24
+ SelectionRect, QNodeEditor, QNodePortSpec
25
+ )
26
+
27
+
28
+ class TestEnums(unittest.TestCase):
29
+ """测试枚举类"""
30
+
31
+ def testNodeState(self) -> None:
32
+ """测试NodeState枚举"""
33
+ self.assertEqual(NodeState.IDLE.name, "IDLE")
34
+ self.assertEqual(NodeState.HOVER.name, "HOVER")
35
+ self.assertEqual(NodeState.SELECTED.name, "SELECTED")
36
+
37
+ def testNodeShape(self) -> None:
38
+ """测试NodeShape枚举"""
39
+ self.assertEqual(NodeShape.ELLIPSE.name, "ELLIPSE")
40
+ self.assertEqual(NodeShape.RECTANGLE.name, "RECTANGLE")
41
+ self.assertEqual(NodeShape.DIAMOND.name, "DIAMOND")
42
+
43
+ def testArrowState(self) -> None:
44
+ """测试ArrowState枚举"""
45
+ self.assertEqual(ArrowState.IDLE.name, "IDLE")
46
+ self.assertEqual(ArrowState.HOVER.name, "HOVER")
47
+ self.assertEqual(ArrowState.SELECTED.name, "SELECTED")
48
+
49
+ def testConnectionType(self) -> None:
50
+ """测试ConnectionType枚举"""
51
+ self.assertEqual(ConnectionType.BEZIER.name, "BEZIER")
52
+ self.assertEqual(ConnectionType.STRAIGHT.name, "STRAIGHT")
53
+
54
+
55
+ class TestStyles(unittest.TestCase):
56
+ """测试样式类"""
57
+
58
+ def testNodeStyle(self) -> None:
59
+ """测试NodeStyle"""
60
+ style = NodeStyle()
61
+ self.assertIsNotNone(style.idle_fill)
62
+ self.assertIsNotNone(style.idle_stroke)
63
+ self.assertIsNotNone(style.hover_overlay)
64
+ self.assertIsNotNone(style.selected_stroke)
65
+ self.assertIsNotNone(style.selected_overlay)
66
+ self.assertEqual(style.stroke_width, 2.0)
67
+ self.assertIsNotNone(style.text_color)
68
+
69
+ def testConnectionStyle(self) -> None:
70
+ """测试ConnectionStyle"""
71
+ style = ConnectionStyle()
72
+ self.assertIsNotNone(style.normal_color)
73
+ self.assertIsNotNone(style.selected_color)
74
+ self.assertIsNotNone(style.hover_color)
75
+ self.assertEqual(style.stroke_width, 2.0)
76
+ self.assertEqual(style.selected_stroke_width, 3.0)
77
+
78
+ def testArrowStyle(self) -> None:
79
+ """测试ArrowStyle"""
80
+ style = ArrowStyle()
81
+ self.assertIsNotNone(style.normal_color)
82
+ self.assertIsNotNone(style.selected_color)
83
+ self.assertIsNotNone(style.hover_color)
84
+ self.assertEqual(style.arrow_size, 12.0)
85
+ self.assertEqual(style.stroke_width, 2.0)
86
+
87
+
88
+ class TestNodeItem(unittest.TestCase):
89
+ """测试NodeItem类"""
90
+
91
+ @classmethod
92
+ def setUpClass(cls) -> None:
93
+ """设置测试类"""
94
+ cls.app = QApplication.instance()
95
+ if cls.app is None:
96
+ cls.app = QApplication(sys.argv)
97
+
98
+ def setUp(self) -> None:
99
+ """设置每个测试"""
100
+ self.scene = QGraphicsScene()
101
+ self.node = QNodeItem("TestNode", QPointF(100, 100), NodeShape.ELLIPSE)
102
+ self.scene.addItem(self.node)
103
+
104
+ def tearDown(self) -> None:
105
+ """清理每个测试"""
106
+ self.scene.clear()
107
+
108
+ def testNodeCreation(self) -> None:
109
+ """测试节点创建"""
110
+ self.assertEqual(self.node.name, "TestNode")
111
+ self.assertEqual(self.node.pos(), QPointF(100, 100))
112
+ self.assertEqual(self.node.nodeShape, NodeShape.ELLIPSE)
113
+ self.assertEqual(self.node.state, NodeState.IDLE)
114
+
115
+ def testNodeNameChange(self) -> None:
116
+ """测试节点名称修改"""
117
+ self.node.name = "NewName"
118
+ self.assertEqual(self.node.name, "NewName")
119
+
120
+ def testNodeShapeChange(self) -> None:
121
+ """测试节点形状修改"""
122
+ self.node.nodeShape = NodeShape.RECTANGLE
123
+ self.assertEqual(self.node.nodeShape, NodeShape.RECTANGLE)
124
+
125
+ self.node.nodeShape = NodeShape.DIAMOND
126
+ self.assertEqual(self.node.nodeShape, NodeShape.DIAMOND)
127
+
128
+ def testNodeData(self) -> None:
129
+ """测试节点数据"""
130
+ # 数据字典应该是可修改的
131
+ self.node.data["key"] = "value"
132
+ self.assertEqual(self.node.data["key"], "value")
133
+
134
+ self.node.data["number"] = 42
135
+ self.assertEqual(self.node.data["number"], 42)
136
+
137
+ def testNodeEditorClass(self) -> None:
138
+ """测试节点编辑器类"""
139
+ self.assertIsNone(self.node.editorClass)
140
+
141
+ # 设置编辑器类
142
+ class TestEditor(QNodeEditor):
143
+ pass
144
+
145
+ self.node.editorClass = TestEditor
146
+ self.assertEqual(self.node.editorClass, TestEditor)
147
+
148
+ def testNodeSelection(self) -> None:
149
+ """测试节点选择"""
150
+ self.node.setSelected(True)
151
+ self.assertTrue(self.node.isSelected())
152
+ self.assertEqual(self.node.state, NodeState.SELECTED)
153
+
154
+ self.node.setSelected(False)
155
+ self.assertFalse(self.node.isSelected())
156
+ self.assertEqual(self.node.state, NodeState.IDLE)
157
+
158
+ def testNodeToDict(self) -> None:
159
+ """测试节点转字典"""
160
+ self.node.data["test"] = "data"
161
+ data = self.node.toDict()
162
+
163
+ self.assertIn("id", data)
164
+ self.assertEqual(data["name"], "TestNode")
165
+ self.assertEqual(data["shape"], "ELLIPSE")
166
+ self.assertIn("position", data)
167
+ self.assertIn("data", data)
168
+ self.assertEqual(data["data"]["test"], "data")
169
+
170
+ def testNodeFromDict(self) -> None:
171
+ """测试节点从字典更新"""
172
+ data = {
173
+ "name": "UpdatedName",
174
+ "shape": "RECTANGLE",
175
+ "position": {"x": 200, "y": 300},
176
+ "data": {"custom": "value"}
177
+ }
178
+ self.node.fromDict(data)
179
+
180
+ self.assertEqual(self.node.name, "UpdatedName")
181
+ self.assertEqual(self.node.nodeShape, NodeShape.RECTANGLE)
182
+ self.assertEqual(self.node.pos(), QPointF(200, 300))
183
+ self.assertEqual(self.node.data["custom"], "value")
184
+
185
+ def testGetEdgePointEllipse(self) -> None:
186
+ """测试圆形节点边缘点计算"""
187
+ self.node.shape = NodeShape.ELLIPSE
188
+ center = self.node.sceneBoundingRect().center()
189
+ target = QPointF(center.x() + 200, center.y())
190
+ edge = self.node.getEdgePoint(target)
191
+
192
+ # 边缘点应该在中心右侧
193
+ self.assertGreater(edge.x(), center.x())
194
+
195
+ def testGetEdgePointRectangle(self) -> None:
196
+ """测试矩形节点边缘点计算"""
197
+ self.node.shape = NodeShape.RECTANGLE
198
+ center = self.node.sceneBoundingRect().center()
199
+ target = QPointF(center.x() + 200, center.y())
200
+ edge = self.node.getEdgePoint(target)
201
+
202
+ # 边缘点应该在中心右侧
203
+ self.assertGreater(edge.x(), center.x())
204
+
205
+ def testGetEdgePointDiamond(self) -> None:
206
+ """测试菱形节点边缘点计算"""
207
+ self.node.shape = NodeShape.DIAMOND
208
+ center = self.node.sceneBoundingRect().center()
209
+ target = QPointF(center.x() + 200, center.y())
210
+ edge = self.node.getEdgePoint(target)
211
+
212
+ # 边缘点应该在中心右侧
213
+ self.assertGreater(edge.x(), center.x())
214
+
215
+ def testSetColor(self) -> None:
216
+ """测试设置节点颜色"""
217
+
218
+ # 设置新颜色
219
+ new_color = QColor(255, 0, 0) # 红色
220
+ self.node.setColor(new_color)
221
+
222
+ # 验证颜色已设置
223
+ self.assertEqual(self.node._style.idle_fill, new_color)
224
+
225
+ def testSetBorder(self) -> None:
226
+ """测试设置节点边框"""
227
+
228
+ # 设置边框宽度和颜色
229
+ border_color = QColor(0, 255, 0) # 绿色
230
+ self.node.setBorder(3, border_color)
231
+
232
+ # 验证边框设置
233
+ self.assertEqual(self.node._style.stroke_width, 3)
234
+ self.assertEqual(self.node._style.idle_stroke, border_color)
235
+
236
+ # 测试无边框(宽度<=0)
237
+ self.node.setBorder(0)
238
+ # 应该设置NoPen
239
+ pen = self.node.pen()
240
+ self.assertEqual(pen.style(), Qt.PenStyle.NoPen)
241
+
242
+ def testNodeNameEditingState(self) -> None:
243
+ """测试节点名称编辑状态"""
244
+ # 初始状态应该不在编辑
245
+ self.assertFalse(self.node.isEditingName)
246
+
247
+ # 开始编辑
248
+ self.node._startNameEdit()
249
+ self.assertTrue(self.node.isEditingName)
250
+
251
+ # 完成编辑
252
+ self.node._finishNameEdit()
253
+ self.assertFalse(self.node.isEditingName)
254
+
255
+ def testNodeNameEditCancel(self) -> None:
256
+ """测试编辑节点名称后确认,名称应更新为新文本"""
257
+ # 开始编辑
258
+ self.node._startNameEdit()
259
+ self.assertTrue(self.node.isEditingName)
260
+
261
+ # 原位编辑:文本保存在 _text_item 中(旧版 _name_edit/QLineEdit 已弃用)
262
+ self.node._text_item.setPlainText("NewName")
263
+
264
+ # 完成编辑(_finishNameEdit 读取 _text_item 文本并更新名称)
265
+ self.node._finishNameEdit()
266
+ self.assertFalse(self.node.isEditingName)
267
+ self.assertEqual(self.node.name, "NewName")
268
+
269
+ def testNodeNameEditInPlace(self) -> None:
270
+ """测试节点名称编辑使用原位 QGraphicsTextItem 编辑"""
271
+ # 开始编辑
272
+ self.node._startNameEdit()
273
+ self.assertTrue(self.node.isEditingName)
274
+
275
+ # 原位编辑:_text_item 启用文本编辑交互
276
+ flags = self.node._text_item.textInteractionFlags()
277
+ self.assertEqual(flags, Qt.TextInteractionFlag.TextEditorInteraction)
278
+
279
+ # _startNameEdit 会选中全部文本,方便覆盖输入(不依赖视图焦点)
280
+ self.assertTrue(self.node._text_item.textCursor().hasSelection())
281
+
282
+ # 完成编辑
283
+ self.node._finishNameEdit()
284
+ self.assertFalse(self.node.isEditingName)
285
+ # 完成后恢复为不可编辑
286
+ flags = self.node._text_item.textInteractionFlags()
287
+ self.assertEqual(flags, Qt.TextInteractionFlag.NoTextInteraction)
288
+
289
+
290
+ class TestConnectionItem(unittest.TestCase):
291
+ """测试ConnectionItem类"""
292
+
293
+ @classmethod
294
+ def setUpClass(cls) -> None:
295
+ """设置测试类"""
296
+ cls.app = QApplication.instance()
297
+ if cls.app is None:
298
+ cls.app = QApplication(sys.argv)
299
+
300
+ def setUp(self) -> None:
301
+ """设置每个测试"""
302
+ self.scene = QGraphicsScene()
303
+ self.source_node = QNodeItem("Source", QPointF(0, 0))
304
+ self.target_node = QNodeItem("Target", QPointF(200, 0))
305
+ self.scene.addItem(self.source_node)
306
+ self.scene.addItem(self.target_node)
307
+
308
+ self.connection = ConnectionItem(
309
+ self.source_node, self.target_node,
310
+ is_bidirectional=False,
311
+ connection_type=ConnectionType.BEZIER
312
+ )
313
+ self.scene.addItem(self.connection)
314
+ self.scene.addItem(self.connection.targetArrow)
315
+
316
+ def tearDown(self) -> None:
317
+ """清理每个测试"""
318
+ self.connection.removeArrows()
319
+ self.scene.removeItem(self.connection)
320
+ self.scene.clear()
321
+
322
+ def testConnectionCreation(self) -> None:
323
+ """测试连接创建"""
324
+ self.assertEqual(self.connection.sourceNode, self.source_node)
325
+ self.assertEqual(self.connection.targetNode, self.target_node)
326
+ self.assertFalse(self.connection.isBidirectional)
327
+ self.assertEqual(self.connection.connectionType, ConnectionType.BEZIER)
328
+
329
+ def testConnectionTypeToggle(self) -> None:
330
+ """测试连接类型切换"""
331
+ self.assertEqual(self.connection.connectionType, ConnectionType.BEZIER)
332
+
333
+ self.connection.toggleConnectionType()
334
+ self.assertEqual(self.connection.connectionType, ConnectionType.STRAIGHT)
335
+
336
+ self.connection.toggleConnectionType()
337
+ self.assertEqual(self.connection.connectionType, ConnectionType.BEZIER)
338
+
339
+ def testBidirectionalToggle(self) -> None:
340
+ """测试双向连接切换"""
341
+ self.assertFalse(self.connection.isBidirectional)
342
+
343
+ self.connection.isBidirectional = True
344
+ self.assertTrue(self.connection.isBidirectional)
345
+ self.assertIsNotNone(self.connection.sourceArrow)
346
+
347
+ self.connection.isBidirectional = False
348
+ self.assertFalse(self.connection.isBidirectional)
349
+ self.assertIsNone(self.connection.sourceArrow)
350
+
351
+ def testSwapDirection(self) -> None:
352
+ """测试连接方向交换"""
353
+ original_source = self.connection.sourceNode
354
+ original_target = self.connection.targetNode
355
+
356
+ self.connection.swapDirection()
357
+
358
+ self.assertEqual(self.connection.sourceNode, original_target)
359
+ self.assertEqual(self.connection.targetNode, original_source)
360
+
361
+ def testConnectionSetColor(self) -> None:
362
+ """测试设置连接线颜色"""
363
+
364
+ new_color = QColor(255, 0, 0) # 红色
365
+ self.connection.setColor(new_color)
366
+
367
+ # 验证颜色已设置
368
+ self.assertEqual(self.connection._style.normal_color, new_color)
369
+
370
+ def testConnectionSetBorder(self) -> None:
371
+ """测试设置连接线边框(线宽)"""
372
+
373
+ # 设置线宽和颜色
374
+ border_color = QColor(0, 255, 0) # 绿色
375
+ self.connection.setBorder(5, border_color)
376
+
377
+ # 验证设置
378
+ self.assertEqual(self.connection._style.stroke_width, 5)
379
+
380
+
381
+ class TestArrowItem(unittest.TestCase):
382
+ """测试ArrowItem类"""
383
+
384
+ @classmethod
385
+ def setUpClass(cls) -> None:
386
+ """设置测试类"""
387
+ cls.app = QApplication.instance()
388
+ if cls.app is None:
389
+ cls.app = QApplication(sys.argv)
390
+
391
+ def setUp(self) -> None:
392
+ """设置每个测试"""
393
+ self.scene = QGraphicsScene()
394
+ self.source_node = QNodeItem("Source", QPointF(0, 0))
395
+ self.target_node = QNodeItem("Target", QPointF(200, 0))
396
+ self.scene.addItem(self.source_node)
397
+ self.scene.addItem(self.target_node)
398
+
399
+ self.connection = ConnectionItem(self.source_node, self.target_node)
400
+ self.scene.addItem(self.connection)
401
+ self.arrow = self.connection.targetArrow
402
+
403
+ def tearDown(self) -> None:
404
+ """清理每个测试"""
405
+ self.connection.removeArrows()
406
+ self.scene.removeItem(self.connection)
407
+ self.scene.clear()
408
+
409
+ def testArrowCreation(self) -> None:
410
+ """测试箭头创建"""
411
+ self.assertEqual(self.arrow.connection, self.connection)
412
+ self.assertFalse(self.arrow.isSourceArrow)
413
+
414
+ def testArrowSelection(self) -> None:
415
+ """测试箭头选择"""
416
+ self.arrow.setSelected(True)
417
+ self.assertTrue(self.arrow.isSelected())
418
+
419
+ self.arrow.setSelected(False)
420
+ self.assertFalse(self.arrow.isSelected())
421
+
422
+ def testArrowSetColor(self) -> None:
423
+ """测试设置箭头颜色"""
424
+
425
+ new_color = QColor(255, 0, 0) # 红色
426
+ self.arrow.setColor(new_color)
427
+
428
+ # 验证颜色已设置
429
+ self.assertEqual(self.arrow._style.normal_color, new_color)
430
+
431
+ def testArrowSetBorder(self) -> None:
432
+ """测试设置箭头边框"""
433
+
434
+ # 设置边框宽度和颜色
435
+ border_color = QColor(0, 255, 0) # 绿色
436
+ self.arrow.setBorder(3, border_color)
437
+
438
+ # 验证设置
439
+ self.assertEqual(self.arrow._style.stroke_width, 3)
440
+
441
+
442
+ class TestSelectionRect(unittest.TestCase):
443
+ """测试SelectionRect类"""
444
+
445
+ @classmethod
446
+ def setUpClass(cls) -> None:
447
+ """设置测试类"""
448
+ cls.app = QApplication.instance()
449
+ if cls.app is None:
450
+ cls.app = QApplication(sys.argv)
451
+
452
+ def setUp(self) -> None:
453
+ """设置每个测试"""
454
+ self.scene = QGraphicsScene()
455
+ self.selection_rect = SelectionRect()
456
+ self.scene.addItem(self.selection_rect)
457
+
458
+ def tearDown(self) -> None:
459
+ """清理每个测试"""
460
+ self.scene.clear()
461
+
462
+ def testSelectionRectCreation(self) -> None:
463
+ """测试框选矩形创建"""
464
+ self.assertIsNotNone(self.selection_rect)
465
+ self.assertEqual(self.selection_rect.zValue(), 1000)
466
+
467
+ def testSelectionRectSetColor(self) -> None:
468
+ """测试设置框选矩形颜色"""
469
+
470
+ new_color = QColor(255, 0, 0, 100) # 半透明红色
471
+ self.selection_rect.setColor(new_color)
472
+
473
+ # 验证颜色已设置
474
+ self.assertEqual(self.selection_rect._fill_color, new_color)
475
+
476
+ def testSelectionRectSetBorder(self) -> None:
477
+ """测试设置框选矩形边框"""
478
+
479
+ # 设置边框宽度和颜色
480
+ border_color = QColor(0, 255, 0) # 绿色
481
+ self.selection_rect.setBorder(3, border_color)
482
+
483
+ # 验证设置
484
+ self.assertEqual(self.selection_rect._border_width, 3)
485
+ self.assertEqual(self.selection_rect._border_color, border_color)
486
+
487
+
488
+ class TestNodeCanvas(unittest.TestCase):
489
+ """测试NodeCanvas类"""
490
+
491
+ @classmethod
492
+ def setUpClass(cls) -> None:
493
+ """设置测试类"""
494
+ cls.app = QApplication.instance()
495
+ if cls.app is None:
496
+ cls.app = QApplication(sys.argv)
497
+
498
+ def setUp(self) -> None:
499
+ """设置每个测试"""
500
+ self.canvas = QNodeCanvas()
501
+ self.canvas.resize(800, 600)
502
+ self.canvas.show()
503
+
504
+ def tearDown(self) -> None:
505
+ """清理每个测试"""
506
+ self.canvas.close()
507
+
508
+ def testCanvasCreation(self) -> None:
509
+ """测试画布创建"""
510
+ self.assertIsNotNone(self.canvas.scene())
511
+ self.assertEqual(len(self.canvas.nodes), 0)
512
+ self.assertEqual(len(self.canvas.connections), 0)
513
+
514
+ def testAddNode(self) -> None:
515
+ """测试添加节点"""
516
+ node = self.canvas.addNode("TestNode", QPointF(100, 100))
517
+ self.assertIn(node, self.canvas.nodes)
518
+ self.assertEqual(node.name, "TestNode")
519
+
520
+ def testRemoveNode(self) -> None:
521
+ """测试移除节点"""
522
+ node = self.canvas.addNode("TestNode")
523
+ self.canvas.removeNode(node)
524
+ self.assertNotIn(node, self.canvas.nodes)
525
+
526
+ def testAddConnection(self) -> None:
527
+ """测试添加连接"""
528
+ node1 = self.canvas.addNode("Node1", QPointF(0, 0))
529
+ node2 = self.canvas.addNode("Node2", QPointF(200, 0))
530
+
531
+ connection = self.canvas.addConnection(node1, node2)
532
+ self.assertIn(connection, self.canvas.connections)
533
+ self.assertEqual(connection.sourceNode, node1)
534
+ self.assertEqual(connection.targetNode, node2)
535
+
536
+ def testRemoveConnection(self) -> None:
537
+ """测试移除连接"""
538
+ node1 = self.canvas.addNode("Node1")
539
+ node2 = self.canvas.addNode("Node2")
540
+ connection = self.canvas.addConnection(node1, node2)
541
+
542
+ self.canvas.removeConnection(connection)
543
+ self.assertNotIn(connection, self.canvas.connections)
544
+
545
+ def testStartNode(self) -> None:
546
+ """测试起点节点"""
547
+ node = self.canvas.addNode("StartNode")
548
+ self.canvas.startNode = node
549
+
550
+ self.assertEqual(self.canvas.startNode, node)
551
+
552
+ def testExportImport(self) -> None:
553
+ """测试导出导入"""
554
+ # 创建一些节点和连接
555
+ node1 = self.canvas.addNode("Node1", QPointF(0, 0))
556
+ node2 = self.canvas.addNode("Node2", QPointF(100, 100))
557
+ self.canvas.addConnection(node1, node2)
558
+ self.canvas.startNode = node1
559
+
560
+ # 导出(现在返回字典)
561
+ data = self.canvas.export()
562
+ self.assertIsInstance(data, dict)
563
+ self.assertIn("nodes", data)
564
+ self.assertIn("connections", data)
565
+
566
+ # 测试导出为 JSON 字符串
567
+ json_str = self.canvas.exportToJson()
568
+ self.assertIsInstance(json_str, str)
569
+ self.assertIn("nodes", json_str)
570
+
571
+ # 清空
572
+ self.canvas.clear()
573
+ self.assertEqual(len(self.canvas.nodes), 0)
574
+ self.assertEqual(len(self.canvas.connections), 0)
575
+
576
+ # 导入字典
577
+ self.canvas.importData(data)
578
+ self.assertEqual(len(self.canvas.nodes), 2)
579
+ self.assertEqual(len(self.canvas.connections), 1)
580
+
581
+ def testClear(self) -> None:
582
+ """测试清空画布"""
583
+ node1 = self.canvas.addNode("Node1")
584
+ node2 = self.canvas.addNode("Node2")
585
+ self.canvas.addConnection(node1, node2)
586
+
587
+ self.canvas.clear()
588
+ self.assertEqual(len(self.canvas.nodes), 0)
589
+ self.assertEqual(len(self.canvas.connections), 0)
590
+
591
+ def testChangedState(self) -> None:
592
+ """测试修改状态"""
593
+ self.assertFalse(self.canvas.changed)
594
+
595
+ self.canvas.addNode("TestNode")
596
+ self.assertTrue(self.canvas.changed)
597
+
598
+ def testClickOutsideToCancelNameEdit(self) -> None:
599
+ """测试点击画布外部取消节点名称编辑"""
600
+ # 添加节点
601
+ node = self.canvas.addNode("TestNode", QPointF(100, 100))
602
+
603
+ # 开始编辑
604
+ node._startNameEdit()
605
+ self.assertTrue(node.isEditingName)
606
+
607
+ # 原位编辑无独立代理控件,使用节点自身的场景边界来定位
608
+ node_rect = node.sceneBoundingRect()
609
+ edit_center = node_rect.center()
610
+
611
+ # 模拟点击画布外部(远离节点)
612
+ outside_pos = QPointF(edit_center.x() + 200, edit_center.y() + 200)
613
+
614
+ # 创建鼠标事件
615
+
616
+ # 将场景坐标转换为视图坐标
617
+ view_pos = self.canvas.mapFromScene(outside_pos)
618
+
619
+ # 发送鼠标按下事件
620
+ QTest.mouseClick(self.canvas.viewport(), Qt.MouseButton.LeftButton,
621
+ pos=view_pos)
622
+
623
+ # 检查编辑是否被取消
624
+ self.assertFalse(node.isEditingName)
625
+
626
+ def testEdgeDetectionEllipse(self) -> None:
627
+ """测试圆形节点边缘检测"""
628
+ node = self.canvas.addNode("EllipseNode", QPointF(100, 100), NodeShape.ELLIPSE)
629
+ center = node.sceneBoundingRect().center()
630
+ radius = node.radius
631
+
632
+ # 边缘位置(应该检测到)
633
+ edge_pos = QPointF(center.x() + radius * 0.9, center.y())
634
+ self.assertTrue(self.canvas._is_on_node_edge(node, edge_pos))
635
+
636
+ # 中心位置(不应该检测到)
637
+ center_pos = QPointF(center.x() + radius * 0.5, center.y())
638
+ self.assertFalse(self.canvas._is_on_node_edge(node, center_pos))
639
+
640
+ # 外部位置(不应该检测到)
641
+ outside_pos = QPointF(center.x() + radius * 1.2, center.y())
642
+ self.assertFalse(self.canvas._is_on_node_edge(node, outside_pos))
643
+
644
+ def testEdgeDetectionRectangle(self) -> None:
645
+ """测试矩形节点边缘检测,包括角落区域"""
646
+ node = self.canvas.addNode("RectNode", QPointF(100, 100), NodeShape.RECTANGLE)
647
+ center = node.sceneBoundingRect().center()
648
+ rect = node.rect()
649
+ half_w = rect.width() / 2
650
+ half_h = rect.height() / 2
651
+
652
+ # 右边缘位置(应该检测到)
653
+ right_edge = QPointF(center.x() + half_w * 0.9, center.y())
654
+ self.assertTrue(self.canvas._is_on_node_edge(node, right_edge))
655
+
656
+ # 左边缘位置(应该检测到)
657
+ left_edge = QPointF(center.x() - half_w * 0.9, center.y())
658
+ self.assertTrue(self.canvas._is_on_node_edge(node, left_edge))
659
+
660
+ # 上边缘位置(应该检测到)
661
+ top_edge = QPointF(center.x(), center.y() - half_h * 0.9)
662
+ self.assertTrue(self.canvas._is_on_node_edge(node, top_edge))
663
+
664
+ # 下边缘位置(应该检测到)
665
+ bottom_edge = QPointF(center.x(), center.y() + half_h * 0.9)
666
+ self.assertTrue(self.canvas._is_on_node_edge(node, bottom_edge))
667
+
668
+ # 角落区域(应该检测到)- 在圆角边缘附近
669
+ # 计算一个更靠近实际边界的测试点
670
+ inner_w = half_w - min(half_w, half_h) * 0.2
671
+ inner_h = half_h - min(half_w, half_h) * 0.2
672
+ # 在角落附近,稍微超出内矩形边界
673
+ corner_pos = QPointF(center.x() + inner_w * 1.05, center.y() + inner_h * 1.05)
674
+ self.assertTrue(self.canvas._is_on_node_edge(node, corner_pos))
675
+
676
+ # 中心位置(不应该检测到)
677
+ center_pos = QPointF(center.x(), center.y())
678
+ self.assertFalse(self.canvas._is_on_node_edge(node, center_pos))
679
+
680
+ # 外部位置(不应该检测到)
681
+ outside_pos = QPointF(center.x() + half_w * 1.2, center.y())
682
+ self.assertFalse(self.canvas._is_on_node_edge(node, outside_pos))
683
+
684
+ def testEdgeDetectionRectangleCorners(self) -> None:
685
+ """测试矩形节点四个角落的边缘检测"""
686
+ node = self.canvas.addNode("RectNode", QPointF(100, 100), NodeShape.RECTANGLE)
687
+ center = node.sceneBoundingRect().center()
688
+ rect = node.rect()
689
+ half_w = rect.width() / 2
690
+ half_h = rect.height() / 2
691
+ corner_radius = min(half_w, half_h) * 0.2
692
+ inner_w = half_w - corner_radius
693
+ inner_h = half_h - corner_radius
694
+
695
+ # 测试四个角落(在圆角边缘附近)
696
+ # 需要确保测试点在 inner_w/h 之外,但在 half_w/h 之内
697
+ # 使用 inner_w * 1.1 确保在圆角区域内
698
+ corners = [
699
+ ("左下", QPointF(center.x() - inner_w * 1.1, center.y() + inner_h * 1.1)),
700
+ ("右下", QPointF(center.x() + inner_w * 1.1, center.y() + inner_h * 1.1)),
701
+ ("左上", QPointF(center.x() - inner_w * 1.1, center.y() - inner_h * 1.1)),
702
+ ("右上", QPointF(center.x() + inner_w * 1.1, center.y() - inner_h * 1.1)),
703
+ ]
704
+
705
+ for name, pos in corners:
706
+ with self.subTest(corner=name):
707
+ self.assertTrue(
708
+ self.canvas._is_on_node_edge(node, pos),
709
+ f"{name}角落应该被检测为边缘区域"
710
+ )
711
+
712
+ def testEdgeDetectionDiamond(self) -> None:
713
+ """测试菱形节点边缘检测"""
714
+ node = self.canvas.addNode("DiamondNode", QPointF(100, 100), NodeShape.DIAMOND)
715
+ center = node.sceneBoundingRect().center()
716
+ radius = node.radius
717
+
718
+ # 右顶点边缘位置(应该检测到)
719
+ right_edge = QPointF(center.x() + radius * 0.9, center.y())
720
+ self.assertTrue(self.canvas._is_on_node_edge(node, right_edge))
721
+
722
+ # 上顶点边缘位置(应该检测到)
723
+ top_edge = QPointF(center.x(), center.y() - radius * 0.9)
724
+ self.assertTrue(self.canvas._is_on_node_edge(node, top_edge))
725
+
726
+ # 中心位置(不应该检测到)
727
+ center_pos = QPointF(center.x() + radius * 0.3, center.y() + radius * 0.3)
728
+ self.assertFalse(self.canvas._is_on_node_edge(node, center_pos))
729
+
730
+ # 外部位置(不应该检测到)
731
+ outside_pos = QPointF(center.x() + radius * 1.2, center.y())
732
+ self.assertFalse(self.canvas._is_on_node_edge(node, outside_pos))
733
+
734
+ def testDuplicateConnectionFlashError(self) -> None:
735
+ """测试重复连接时触发错误闪烁"""
736
+ node1 = self.canvas.addNode("Node1", QPointF(0, 0))
737
+ node2 = self.canvas.addNode("Node2", QPointF(200, 0))
738
+
739
+ # 创建第一个连接
740
+ conn1 = self.canvas.addConnection(node1, node2)
741
+ self.assertEqual(len(self.canvas.connections), 1)
742
+
743
+ # 尝试创建重复连接,应该返回已存在的连接并触发 flashError
744
+ conn2 = self.canvas.addConnection(node1, node2)
745
+ self.assertEqual(conn1, conn2) # 返回同一个连接
746
+ self.assertEqual(len(self.canvas.connections), 1) # 连接数量不变
747
+
748
+ def testReverseConnectionBecomesBidirectional(self) -> None:
749
+ """测试反向连接转换为双向连接"""
750
+ node1 = self.canvas.addNode("Node1", QPointF(0, 0))
751
+ node2 = self.canvas.addNode("Node2", QPointF(200, 0))
752
+
753
+ # 创建 node1 -> node2 的连接
754
+ conn1 = self.canvas.addConnection(node1, node2)
755
+ self.assertFalse(conn1.isBidirectional)
756
+ self.assertEqual(len(self.canvas.connections), 1)
757
+
758
+ # 创建反向连接 node2 -> node1,应该将原连接变为双向
759
+ conn2 = self.canvas.addConnection(node2, node1)
760
+ self.assertTrue(conn1.isBidirectional) # 原连接变为双向
761
+ self.assertEqual(conn1, conn2) # 返回同一个连接
762
+ self.assertEqual(len(self.canvas.connections), 1) # 连接数量不变
763
+
764
+ def testDuplicateBidirectionalConnectionFlashError(self) -> None:
765
+ """测试重复双向连接时触发错误闪烁"""
766
+ node1 = self.canvas.addNode("Node1", QPointF(0, 0))
767
+ node2 = self.canvas.addNode("Node2", QPointF(200, 0))
768
+
769
+ # 创建双向连接
770
+ conn1 = self.canvas.addConnection(node1, node2, is_bidirectional=True)
771
+ self.assertTrue(conn1.isBidirectional)
772
+
773
+ # 尝试创建反向连接,应该触发 flashError
774
+ conn2 = self.canvas.addConnection(node2, node1)
775
+ self.assertEqual(conn1, conn2)
776
+ self.assertTrue(conn1.isBidirectional) # 保持双向
777
+
778
+
779
+ class TestQNodeEditor(unittest.TestCase):
780
+ """测试QNodeEditor类"""
781
+
782
+ @classmethod
783
+ def setUpClass(cls) -> None:
784
+ """设置测试类"""
785
+ cls.app = QApplication.instance()
786
+ if cls.app is None:
787
+ cls.app = QApplication(sys.argv)
788
+
789
+ def setUp(self) -> None:
790
+ """设置每个测试"""
791
+ self.scene = QGraphicsScene()
792
+ self.node = QNodeItem("TestNode")
793
+ self.scene.addItem(self.node)
794
+ self.editor = QNodeEditor(self.node)
795
+
796
+ def tearDown(self) -> None:
797
+ """清理每个测试"""
798
+ self.editor.close()
799
+ self.scene.clear()
800
+
801
+ def testEditorCreation(self) -> None:
802
+ """测试编辑器创建"""
803
+ self.assertEqual(self.editor.node, self.node)
804
+
805
+ def testEditorTitle(self) -> None:
806
+ """测试编辑器标题"""
807
+ title = self.editor.getTitle()
808
+ self.assertIn("TestNode", title)
809
+
810
+ def testEditorSaveLoad(self) -> None:
811
+ """测试编辑器保存和加载"""
812
+ self.assertTrue(self.editor.save())
813
+ self.assertTrue(self.editor.load())
814
+
815
+
816
+ class TestNodeShapes(unittest.TestCase):
817
+ """测试不同节点形状"""
818
+
819
+ @classmethod
820
+ def setUpClass(cls) -> None:
821
+ """设置测试类"""
822
+ cls.app = QApplication.instance()
823
+ if cls.app is None:
824
+ cls.app = QApplication(sys.argv)
825
+
826
+ def testEllipseShape(self) -> None:
827
+ """测试椭圆形"""
828
+ node = QNodeItem("EllipseNode", shape=NodeShape.ELLIPSE)
829
+ self.assertEqual(node.nodeShape, NodeShape.ELLIPSE)
830
+
831
+ # 测试边缘点计算
832
+ center = QPointF(100, 100)
833
+ node.setPos(center)
834
+ target = QPointF(200, 100)
835
+ edge = node.getEdgePoint(target)
836
+ self.assertIsNotNone(edge)
837
+
838
+ def testRectangleShape(self) -> None:
839
+ """测试矩形"""
840
+ node = QNodeItem("RectNode", shape=NodeShape.RECTANGLE)
841
+ self.assertEqual(node.nodeShape, NodeShape.RECTANGLE)
842
+
843
+ # 测试边缘点计算
844
+ center = QPointF(100, 100)
845
+ node.setPos(center)
846
+ target = QPointF(200, 100)
847
+ edge = node.getEdgePoint(target)
848
+ self.assertIsNotNone(edge)
849
+
850
+ def testDiamondShape(self) -> None:
851
+ """测试菱形"""
852
+ node = QNodeItem("DiamondNode", shape=NodeShape.DIAMOND)
853
+ self.assertEqual(node.nodeShape, NodeShape.DIAMOND)
854
+
855
+ # 测试边缘点计算
856
+ center = QPointF(100, 100)
857
+ node.setPos(center)
858
+ target = QPointF(200, 100)
859
+ edge = node.getEdgePoint(target)
860
+ self.assertIsNotNone(edge)
861
+
862
+
863
+ class TestArrowDragFunctionality(unittest.TestCase):
864
+ """测试箭头拖拽创建新连接功能"""
865
+
866
+ @classmethod
867
+ def setUpClass(cls) -> None:
868
+ """设置测试类"""
869
+ cls.app = QApplication.instance()
870
+ if cls.app is None:
871
+ cls.app = QApplication(sys.argv)
872
+
873
+ def setUp(self) -> None:
874
+ """设置每个测试"""
875
+ self.canvas = QNodeCanvas()
876
+ self.canvas.show()
877
+ # 添加三个节点用于测试
878
+ self.nodeA = self.canvas.addNode("NodeA", QPointF(0, 0))
879
+ self.nodeB = self.canvas.addNode("NodeB", QPointF(300, 0))
880
+ self.nodeC = self.canvas.addNode("NodeC", QPointF(600, 0))
881
+
882
+ def tearDown(self) -> None:
883
+ """清理每个测试"""
884
+ self.canvas.close()
885
+
886
+ def testArrowDragStateTracking(self) -> None:
887
+ """测试箭头拖拽状态追踪属性"""
888
+ # 创建连接
889
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
890
+ self.assertIsNotNone(conn)
891
+
892
+ # 获取目标箭头
893
+ arrow = conn.targetArrow
894
+ self.assertIsNotNone(arrow)
895
+
896
+ # 检查isDragging初始状态
897
+ self.assertFalse(arrow.isDragging)
898
+ self.assertFalse(arrow.isSourceArrow)
899
+
900
+ def testArrowDragSourceArrowProperty(self) -> None:
901
+ """测试源端箭头属性"""
902
+ # 创建双向连接
903
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB, is_bidirectional=True)
904
+ self.assertIsNotNone(conn)
905
+ self.assertTrue(conn.isBidirectional)
906
+
907
+ # 检查目标箭头
908
+ target_arrow = conn.targetArrow
909
+ self.assertIsNotNone(target_arrow)
910
+ self.assertFalse(target_arrow.isSourceArrow)
911
+
912
+ # 检查源端箭头
913
+ source_arrow = conn.sourceArrow
914
+ self.assertIsNotNone(source_arrow)
915
+ self.assertTrue(source_arrow.isSourceArrow)
916
+
917
+ def testAddConnectionWithCheckPartners(self) -> None:
918
+ """测试addConnection的checkPartners参数"""
919
+ # 创建 A -> B 连接
920
+ conn1 = self.canvas.addConnection(self.nodeA, self.nodeB)
921
+ self.assertIsNotNone(conn1)
922
+ self.assertEqual(len(self.canvas.connections), 1)
923
+
924
+ # 使用 check_partners=False 尝试创建新连接(应该成功)
925
+ conn2 = self.canvas.addConnection(self.nodeA, self.nodeC, check_partners=False)
926
+ self.assertIsNotNone(conn2)
927
+ self.assertEqual(len(self.canvas.connections), 2)
928
+ self.assertIn(conn2, self.canvas.connections)
929
+
930
+ def testAddConnectionSelfConnection(self) -> None:
931
+ """node→node 自环: 允许并创建为环线; port 自指一律拒绝"""
932
+ # node→node 自环: 允许(绘制为一个向外的小圆环)
933
+ conn = self.canvas.addConnection(self.nodeA, self.nodeA)
934
+ self.assertIsNotNone(conn)
935
+ self.assertTrue(conn.isSelfLoop())
936
+ self.assertEqual(len(self.canvas.connections), 1)
937
+ # 重复创建同一自环: 命中已存在, 闪烁并返回已存在连线(不新增)
938
+ conn2 = self.canvas.addConnection(self.nodeA, self.nodeA)
939
+ self.assertIs(conn2, conn)
940
+ self.assertEqual(len(self.canvas.connections), 1)
941
+
942
+ def testAddConnectionPortSelfReference(self) -> None:
943
+ """同节点 port 连接: 异极自环允许, 同极/单端 port 拒绝"""
944
+ from chartflow.node.port_item import QNodePortSpec
945
+ self.nodeA.ports = {
946
+ "left": [QNodePortSpec(name="in")],
947
+ "right": [QNodePortSpec(name="out")],
948
+ }
949
+ in_port = self.nodeA._port_items["left"][0]
950
+ out_port = self.nodeA._port_items["right"][0]
951
+
952
+ # port→port 同极(left→left / right→right): 拒绝
953
+ self.assertIsNone(self.canvas.addConnection(
954
+ self.nodeA, self.nodeA, source_port=in_port, target_port=in_port))
955
+ self.assertIsNone(self.canvas.addConnection(
956
+ self.nodeA, self.nodeA, source_port=out_port, target_port=out_port))
957
+ # port→其所在 node: 拒绝(单端 port, 无意义)
958
+ self.assertIsNone(self.canvas.addConnection(
959
+ self.nodeA, self.nodeA, source_port=out_port))
960
+ # node→其 port: 拒绝(单端 port)
961
+ self.assertIsNone(self.canvas.addConnection(
962
+ self.nodeA, self.nodeA, target_port=in_port))
963
+ self.assertEqual(len(self.canvas.connections), 0)
964
+
965
+ # port→port 异极自环(right→left): 允许, 画为绕外侧的环
966
+ loop = self.canvas.addConnection(
967
+ self.nodeA, self.nodeA, source_port=out_port, target_port=in_port)
968
+ self.assertIsNotNone(loop)
969
+ self.assertTrue(loop.isSelfLoop())
970
+ # left→right 是独立方向: 与 right→left 互不去重, 各自一条
971
+ loop2 = self.canvas.addConnection(
972
+ self.nodeA, self.nodeA, source_port=in_port, target_port=out_port)
973
+ self.assertIsNotNone(loop2)
974
+ self.assertIsNot(loop, loop2)
975
+ self.assertEqual(len(self.canvas.connections), 2)
976
+
977
+ def testAddConnectionPortPolarity(self) -> None:
978
+ """port→port 极性冲突(left→left / right→right)被拒绝, 异极允许"""
979
+ from chartflow.node.port_item import QNodePortSpec
980
+ # A: left=in, right=out; B: left=in, right=out
981
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
982
+ self.nodeB.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
983
+ a_in = self.nodeA._port_items["left"][0]
984
+ a_out = self.nodeA._port_items["right"][0]
985
+ b_in = self.nodeB._port_items["left"][0]
986
+ b_out = self.nodeB._port_items["right"][0]
987
+
988
+ # 同极拒绝: left→left, right→right
989
+ self.assertIsNone(self.canvas.addConnection(
990
+ self.nodeA, self.nodeB, source_port=a_in, target_port=b_in))
991
+ self.assertIsNone(self.canvas.addConnection(
992
+ self.nodeA, self.nodeB, source_port=a_out, target_port=b_out))
993
+ # 异极允许: right→left, left→right
994
+ self.assertIsNotNone(self.canvas.addConnection(
995
+ self.nodeA, self.nodeB, source_port=a_out, target_port=b_in))
996
+ self.assertIsNotNone(self.canvas.addConnection(
997
+ self.nodeA, self.nodeB, source_port=a_in, target_port=b_out))
998
+ # 仅异极两条被创建
999
+ self.assertEqual(len(self.canvas.connections), 2)
1000
+
1001
+ def testSelfRefsProperty(self) -> None:
1002
+ """selfrefs: 只读 property 返回内部 dict(可直接改值), 默认值=既有逻辑"""
1003
+ from chartflow.node.port_item import QNodePortSpec
1004
+ # 默认值 = 既有逻辑(6 个键, right->left 与 left->right 独立)
1005
+ self.assertEqual(self.nodeA.selfrefs, {
1006
+ "node->node": True,
1007
+ "port->port": False,
1008
+ "node->port": False,
1009
+ "port->node": False,
1010
+ "left->right": True,
1011
+ "right->left": True,
1012
+ })
1013
+ # 返回内部 dict(非副本): 直接改值立即生效
1014
+ self.assertIs(self.nodeA.selfrefs, self.nodeA._selfrefs)
1015
+
1016
+ # 配置端口
1017
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1018
+ a_in = self.nodeA._port_items["left"][0]
1019
+ a_out = self.nodeA._port_items["right"][0]
1020
+
1021
+ # 默认: node->port / port->node / port->port 被拒
1022
+ self.assertIsNone(self.canvas.addConnection(self.nodeA, self.nodeA, target_port=a_in))
1023
+ self.assertIsNone(self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out))
1024
+ self.assertIsNone(self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out, target_port=a_out))
1025
+ # 默认: node->node / right->left / left->right 允许
1026
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeA))
1027
+ # right->left 与 left->right 是两条独立边, 各自可创建(不互相去重)
1028
+ rl = self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out, target_port=a_in)
1029
+ lr = self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_in, target_port=a_out)
1030
+ self.assertIsNotNone(rl)
1031
+ self.assertIsNotNone(lr)
1032
+ self.assertIsNot(rl, lr) # 两条独立连线
1033
+ self.assertEqual(len(self.canvas.connections), 3)
1034
+
1035
+ # 开启其余三类, 验证可创建
1036
+ self.nodeA.selfrefs["node->port"] = True
1037
+ self.nodeA.selfrefs["port->node"] = True
1038
+ self.nodeA.selfrefs["port->port"] = True
1039
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeA, target_port=a_in))
1040
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out))
1041
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out, target_port=a_out))
1042
+
1043
+ # 关闭 node->node 不影响已创建, 但阻止新建
1044
+ self.nodeA.selfrefs["node->node"] = False
1045
+ self.assertIsNone(self.canvas.addConnection(self.nodeA, self.nodeA))
1046
+
1047
+ # 仅关闭 right->left: 该方向被拒, left->right 仍可(dedup 命中已存在的 lr)
1048
+ self.nodeA.selfrefs["right->left"] = False
1049
+ self.assertIsNone(self.canvas.addConnection(self.nodeA, self.nodeA, source_port=a_out, target_port=a_in))
1050
+
1051
+ # 全部自指都应渲染为环(isSelfLoop)
1052
+ for c in self.canvas.connections:
1053
+ if c.sourceNode is c.targetNode:
1054
+ self.assertTrue(c.isSelfLoop())
1055
+
1056
+ def testLinkablesDefaults(self) -> None:
1057
+ """linkables: 默认值 = 既有逻辑(7 键齐全, 同极 port 禁止)"""
1058
+ from chartflow.node.node_canvas import _LINKABLES_DEFAULTS
1059
+ self.assertEqual(self.canvas.linkables, {
1060
+ "node->node": True,
1061
+ "node->port": True,
1062
+ "port->node": True,
1063
+ "left->right": True,
1064
+ "right->left": True,
1065
+ "left->left": False,
1066
+ "right->right": False,
1067
+ })
1068
+ # 返回内部 dict(非副本): 直接改值立即生效, 但不自动清理
1069
+ self.assertIs(self.canvas.linkables, self.canvas._linkables)
1070
+ self.assertEqual(self.canvas.linkables, _LINKABLES_DEFAULTS)
1071
+
1072
+ def testLinkablesBlocksCreation(self) -> None:
1073
+ """关闭某 linkables 键后, 对应跨节点连接无法新建"""
1074
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1075
+ self.nodeB.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1076
+ a_out = self.nodeA._port_items["right"][0]
1077
+ b_in = self.nodeB._port_items["left"][0]
1078
+ a_in = self.nodeA._port_items["left"][0]
1079
+ b_out = self.nodeB._port_items["right"][0]
1080
+
1081
+ # 默认: node->node / right->left / left->right 允许, 同极禁止
1082
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeB))
1083
+ self.assertIsNotNone(self.canvas.addConnection(
1084
+ self.nodeA, self.nodeB, source_port=a_out, target_port=b_in))
1085
+ # 关闭 node->node: 阻止新建(已存在的不受影响)
1086
+ self.canvas.setLinkables(node__node=False)
1087
+ self.assertIsNone(self.canvas.addConnection(self.nodeB, self.nodeC))
1088
+ # 恢复, 再关闭 right->left: 阻止新建 right->left
1089
+ self.canvas.setLinkables(node__node=True, right__left=False)
1090
+ self.assertIsNone(self.canvas.addConnection(
1091
+ self.nodeB, self.nodeC, source_port=b_out, target_port=a_in))
1092
+ # left->right 仍允许(默认 True)
1093
+ self.assertIsNotNone(self.canvas.addConnection(
1094
+ self.nodeB, self.nodeC, source_port=b_in, target_port=a_out))
1095
+
1096
+ def testLinkablesEnforceRemovesConnections(self) -> None:
1097
+ """整体赋值/部分修改 linkables 后, 已存在的不合规连接被自动移除"""
1098
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1099
+ self.nodeB.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1100
+ a_out = self.nodeA._port_items["right"][0]
1101
+ b_in = self.nodeB._port_items["left"][0]
1102
+
1103
+ nn = self.canvas.addConnection(self.nodeA, self.nodeB)
1104
+ rl = self.canvas.addConnection(self.nodeA, self.nodeB,
1105
+ source_port=a_out, target_port=b_in)
1106
+ self.assertEqual(len(self.canvas.connections), 2)
1107
+
1108
+ # 整体赋值关闭 node->node: enforceLinkables 应移除 nn, 保留 rl
1109
+ self.canvas.linkables = {"node->node": False}
1110
+ self.assertEqual(len(self.canvas.connections), 1)
1111
+ self.assertIn(rl, self.canvas.connections)
1112
+ self.assertNotIn(nn, self.canvas.connections)
1113
+
1114
+ # 直接改内部 dict(不触发清理): 连接仍在; 手动 enforce 后才移除。
1115
+ # rl 是 right->left(a_out→b_in), 故关闭 right->left 使其违规。
1116
+ self.canvas.linkables["right->left"] = False
1117
+ self.assertEqual(len(self.canvas.connections), 1)
1118
+ removed = self.canvas.enforceLinkables()
1119
+ self.assertEqual(len(self.canvas.connections), 0)
1120
+ self.assertIn(rl, removed)
1121
+
1122
+ def testLinkablesDoesNotAffectSelfRefs(self) -> None:
1123
+ """linkables 只管跨节点; 同节点自指不受 linkables 影响"""
1124
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")], "right": [QNodePortSpec(name="out")]}
1125
+ a_in = self.nodeA._port_items["left"][0]
1126
+ a_out = self.nodeA._port_items["right"][0]
1127
+ # 全部 linkables 关闭
1128
+ self.canvas.linkables = {k: False for k in self.canvas.linkables}
1129
+ # 同节点自指仍由 selfrefs 控制(默认 node->node / left->right / right->left 允许)
1130
+ self.assertIsNotNone(self.canvas.addConnection(self.nodeA, self.nodeA))
1131
+ self.assertIsNotNone(self.canvas.addConnection(
1132
+ self.nodeA, self.nodeA, source_port=a_out, target_port=a_in))
1133
+ # enforceLinkables 不会动自指连线
1134
+ self.canvas.enforceLinkables()
1135
+ self.assertEqual(len(self.canvas.connections), 2)
1136
+
1137
+ def testLinkablesValidation(self) -> None:
1138
+ """未知键 / 类型错误应抛错"""
1139
+ with self.assertRaises(KeyError):
1140
+ self.canvas.linkables = {"bogus": True}
1141
+ with self.assertRaises(TypeError):
1142
+ self.canvas.linkables = {"node->node": "yes"}
1143
+ with self.assertRaises(KeyError):
1144
+ self.canvas.setLinkables(unknown__key=True)
1145
+
1146
+ def testLinkablesRoundTrip(self) -> None:
1147
+ """linkables 经 export/import 往返(仅差异项序列化)"""
1148
+ self.canvas.linkables = {"left->left": True, "node->node": False}
1149
+ data = self.canvas.export()
1150
+ self.assertEqual(data.get("linkables"), {"left->left": True, "node->node": False})
1151
+
1152
+ self.canvas.clear()
1153
+ self.canvas.importData(data)
1154
+ self.assertTrue(self.canvas.linkables["left->left"])
1155
+ self.assertFalse(self.canvas.linkables["node->node"])
1156
+ # 未序列化的键取默认值
1157
+ self.assertTrue(self.canvas.linkables["right->left"])
1158
+ self.assertFalse(self.canvas.linkables["right->right"])
1159
+
1160
+ def testDragTargetArrowCreatesNewConnection(self) -> None:
1161
+ """测试拖拽目标端箭头创建新连接"""
1162
+ # 创建 A -> B 的单向连接
1163
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
1164
+ self.assertEqual(len(self.canvas.connections), 1)
1165
+
1166
+ # 获取目标箭头(从A指向B的箭头)
1167
+ arrow = conn.targetArrow
1168
+
1169
+ # 设置箭头拖拽状态(模拟拖拽开始)
1170
+ arrow._is_dragging = True
1171
+ drag_pos = self.nodeC.sceneBoundingRect().center()
1172
+
1173
+ self.canvas._onArrowDragStarted(drag_pos)
1174
+
1175
+ # 验证临时连接线已创建
1176
+ self.assertIsNotNone(self.canvas._temp_new_connection)
1177
+ self.assertEqual(self.canvas._dragged_arrow, arrow)
1178
+ self.assertEqual(self.canvas._original_connection, conn)
1179
+
1180
+ # 模拟拖拽移动
1181
+ self.canvas._onArrowDragMoved(drag_pos)
1182
+
1183
+ # 模拟拖拽结束到NodeC
1184
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1185
+
1186
+ # 验证新连接已创建 A -> C
1187
+ self.assertEqual(len(self.canvas.connections), 1)
1188
+ new_conn = self.canvas.connections[0]
1189
+ self.assertEqual(new_conn.sourceNode, self.nodeA)
1190
+ self.assertEqual(new_conn.targetNode, self.nodeC)
1191
+
1192
+ def testDragSourceArrowCreatesNewConnection(self) -> None:
1193
+ """测试拖拽源端箭头创建新连接(双向连接情况)"""
1194
+ # 创建 A <-> B 的双向连接
1195
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB, is_bidirectional=True)
1196
+ self.assertEqual(len(self.canvas.connections), 1)
1197
+
1198
+ # 获取源端箭头(从B指向A的箭头)
1199
+ source_arrow = conn.sourceArrow
1200
+ self.assertIsNotNone(source_arrow)
1201
+
1202
+ # 设置箭头拖拽状态(模拟拖拽开始)
1203
+ source_arrow._is_dragging = True
1204
+ drag_pos = self.nodeC.sceneBoundingRect().center()
1205
+ self.canvas._onArrowDragStarted(drag_pos)
1206
+
1207
+ # 验证状态已设置
1208
+ self.assertEqual(self.canvas._dragged_arrow, source_arrow)
1209
+ self.assertEqual(self.canvas._original_connection, conn)
1210
+ self.assertTrue(self.canvas._original_is_bidirectional)
1211
+ self.assertIsNotNone(self.canvas._temp_new_connection)
1212
+
1213
+ # 模拟拖拽结束到NodeC
1214
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1215
+
1216
+ # 验证:原连接变为单向 A -> B
1217
+ # 新连接创建 B -> C
1218
+ self.assertEqual(len(self.canvas.connections), 2)
1219
+
1220
+ # 检查连接:应该有 A->B 和 B->C
1221
+ a_to_b = [c for c in self.canvas.connections
1222
+ if c.sourceNode == self.nodeA and c.targetNode == self.nodeB]
1223
+ b_to_c = [c for c in self.canvas.connections
1224
+ if c.sourceNode == self.nodeB and c.targetNode == self.nodeC]
1225
+ self.assertEqual(len(a_to_b), 1)
1226
+ self.assertEqual(len(b_to_c), 1)
1227
+ self.assertFalse(a_to_b[0].isBidirectional)
1228
+
1229
+ def testDragArrowToSameTargetNoChange(self) -> None:
1230
+ """测试拖拽箭头到相同目标不创建新连接"""
1231
+ # 创建 A -> B 连接
1232
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
1233
+ self.assertEqual(len(self.canvas.connections), 1)
1234
+
1235
+ # 获取目标箭头
1236
+ arrow = conn.targetArrow
1237
+
1238
+ # 设置箭头拖拽状态(模拟拖拽开始)
1239
+ arrow._is_dragging = True
1240
+ drag_pos = self.nodeB.sceneBoundingRect().center()
1241
+ self.canvas._onArrowDragStarted(drag_pos)
1242
+
1243
+ # 模拟拖拽结束到同一目标
1244
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1245
+
1246
+ # 验证:连接数量不变,原连接保持不变
1247
+ self.assertEqual(len(self.canvas.connections), 1)
1248
+ self.assertEqual(self.canvas.connections[0], conn)
1249
+ self.assertEqual(conn.sourceNode, self.nodeA)
1250
+ self.assertEqual(conn.targetNode, self.nodeB)
1251
+
1252
+ def testDragArrowToSameNodeFlashError(self) -> None:
1253
+ """测试拖拽箭头到源节点本身触发错误闪烁"""
1254
+ # 创建 A -> B 连接
1255
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
1256
+
1257
+ # 获取目标箭头
1258
+ arrow = conn.targetArrow
1259
+
1260
+ # 设置箭头拖拽状态(模拟拖拽开始)
1261
+ arrow._is_dragging = True
1262
+ drag_pos = self.nodeA.sceneBoundingRect().center()
1263
+ self.canvas._onArrowDragStarted(drag_pos)
1264
+
1265
+ # 模拟拖拽结束到源节点(自身)
1266
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1267
+
1268
+ # 验证:连接数量不变,原连接保持不变
1269
+ self.assertEqual(len(self.canvas.connections), 1)
1270
+
1271
+ def testDragArrowFailureFlashError(self) -> None:
1272
+ """测试拖拽失败触发错误闪烁"""
1273
+ # 创建 A -> B 连接
1274
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
1275
+
1276
+ # 获取目标箭头
1277
+ arrow = conn.targetArrow
1278
+
1279
+ # 设置箭头拖拽状态(模拟拖拽开始)
1280
+ arrow._is_dragging = True
1281
+ drag_pos = QPointF(1000, 1000) # 远离所有节点的位置
1282
+ self.canvas._onArrowDragStarted(drag_pos)
1283
+
1284
+ # 模拟拖拽失败
1285
+ self.canvas._onArrowDragFinished(drag_pos, is_success=False)
1286
+
1287
+ # 验证状态已重置
1288
+ self.assertIsNone(self.canvas._dragged_arrow)
1289
+ self.assertIsNone(self.canvas._original_connection)
1290
+
1291
+ def testBidirectionalToUnidirectionalConversion(self) -> None:
1292
+ """测试双向连接拖拽后转换为单向连接"""
1293
+ # 创建 A <-> B 的双向连接
1294
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB, is_bidirectional=True)
1295
+ self.assertTrue(conn.isBidirectional)
1296
+ self.assertEqual(len(self.canvas.connections), 1)
1297
+
1298
+ # 获取目标箭头(A->B)
1299
+ target_arrow = conn.targetArrow
1300
+
1301
+ # 设置箭头拖拽状态(模拟拖拽开始)
1302
+ target_arrow._is_dragging = True
1303
+ drag_pos = self.nodeC.sceneBoundingRect().center()
1304
+ self.canvas._onArrowDragStarted(drag_pos)
1305
+
1306
+ # 验证状态已设置
1307
+ self.assertEqual(self.canvas._dragged_arrow, target_arrow)
1308
+ self.assertTrue(self.canvas._original_is_bidirectional)
1309
+
1310
+ # 模拟拖拽结束
1311
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1312
+
1313
+ # 验证:原连接变为 B -> A(单向)
1314
+ # 新连接 A -> C 创建
1315
+ self.assertEqual(len(self.canvas.connections), 2)
1316
+
1317
+ # 找到 B -> A 的连接
1318
+ b_to_a = [c for c in self.canvas.connections
1319
+ if c.sourceNode == self.nodeB and c.targetNode == self.nodeA]
1320
+ self.assertEqual(len(b_to_a), 1)
1321
+ self.assertFalse(b_to_a[0].isBidirectional)
1322
+
1323
+ # 找到 A -> C 的连接
1324
+ a_to_c = [c for c in self.canvas.connections
1325
+ if c.sourceNode == self.nodeA and c.targetNode == self.nodeC]
1326
+ self.assertEqual(len(a_to_c), 1)
1327
+
1328
+ def testDuplicateConnectionWithCheckPartnersFalse(self) -> None:
1329
+ """测试已存在连接时addConnection返回已存在连接"""
1330
+ # 创建 A -> B 连接
1331
+ conn1 = self.canvas.addConnection(self.nodeA, self.nodeB)
1332
+ self.assertIsNotNone(conn1)
1333
+
1334
+ # 尝试再次创建 A -> B,即使 check_partners=False 也应该返回已存在的连接
1335
+ conn2 = self.canvas.addConnection(self.nodeA, self.nodeB, check_partners=False)
1336
+ self.assertEqual(conn1, conn2)
1337
+
1338
+ def testDragArrowToExistingConnectionArrow(self) -> None:
1339
+ """测试拖拽箭头到已存在连接的箭头上(不删除原连接)"""
1340
+ # 创建 A -> B 和 A -> C 两个连接
1341
+ conn_ab = self.canvas.addConnection(self.nodeA, self.nodeB)
1342
+ conn_ac = self.canvas.addConnection(self.nodeA, self.nodeC)
1343
+ self.assertEqual(len(self.canvas.connections), 2)
1344
+ self.assertIn(conn_ab, self.canvas.connections)
1345
+ self.assertIn(conn_ac, self.canvas.connections)
1346
+
1347
+ # 获取 A->B 的目标箭头
1348
+ arrow_ab = conn_ab.targetArrow
1349
+
1350
+ # 拖拽 A->B 的箭头到 C 节点
1351
+ arrow_ab._is_dragging = True
1352
+ drag_pos = self.nodeC.sceneBoundingRect().center()
1353
+ self.canvas._onArrowDragStarted(drag_pos)
1354
+
1355
+ # 验证临时连接线已创建
1356
+ self.assertIsNotNone(self.canvas._temp_new_connection)
1357
+
1358
+ # 模拟拖拽结束到C节点(A->C已存在)
1359
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1360
+
1361
+ # 验证:两个连接都应该仍然存在
1362
+ self.assertEqual(len(self.canvas.connections), 2)
1363
+ self.assertIn(conn_ab, self.canvas.connections)
1364
+ self.assertIn(conn_ac, self.canvas.connections)
1365
+
1366
+ # 验证 A->B 连接没有被删除
1367
+ self.assertEqual(conn_ab.sourceNode, self.nodeA)
1368
+ self.assertEqual(conn_ab.targetNode, self.nodeB)
1369
+
1370
+ def testBidirectionalSourceArrowHasCallbacks(self) -> None:
1371
+ """测试双向连接的 source_arrow 有拖拽回调"""
1372
+ # 创建 A -> B 连接
1373
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB)
1374
+
1375
+ # 创建 B -> A 连接,使 A<->B 成为双向连接
1376
+ # 注意:addConnection 会返回已存在的连接(现在变为双向)
1377
+ conn2 = self.canvas.addConnection(self.nodeB, self.nodeA)
1378
+
1379
+ # 验证是同一个连接且已变为双向
1380
+ self.assertEqual(conn, conn2)
1381
+ self.assertTrue(conn.isBidirectional)
1382
+
1383
+ # 获取 source_arrow(从 B 指向 A 的箭头)
1384
+ source_arrow = conn.sourceArrow
1385
+ self.assertIsNotNone(source_arrow)
1386
+
1387
+ # 验证 source_arrow 有拖拽回调
1388
+ self.assertIsNotNone(source_arrow._drag_started_callback)
1389
+ self.assertIsNotNone(source_arrow._drag_moved_callback)
1390
+ self.assertIsNotNone(source_arrow._drag_finished_callback)
1391
+
1392
+ def testDragBidirectionalSourceArrow(self) -> None:
1393
+ """测试拖拽双向连接的 source_arrow(从 B 指向 A 的箭头)到 C"""
1394
+ # 创建双向连接 A<->B
1395
+ conn = self.canvas.addConnection(self.nodeA, self.nodeB, is_bidirectional=True)
1396
+ self.assertTrue(conn.isBidirectional)
1397
+ self.assertEqual(len(self.canvas.connections), 1)
1398
+
1399
+ # 获取 source_arrow(从 B 指向 A 的箭头)
1400
+ source_arrow = conn.sourceArrow
1401
+ self.assertIsNotNone(source_arrow)
1402
+ self.assertTrue(source_arrow.isSourceArrow)
1403
+
1404
+ # 验证回调已设置
1405
+ self.assertIsNotNone(source_arrow._drag_started_callback)
1406
+
1407
+ # 拖拽 source_arrow 到 C
1408
+ source_arrow._is_dragging = True
1409
+ drag_pos = self.nodeC.sceneBoundingRect().center()
1410
+ self.canvas._onArrowDragStarted(drag_pos)
1411
+
1412
+ # 验证临时连接线已创建
1413
+ self.assertIsNotNone(self.canvas._temp_new_connection)
1414
+ self.assertEqual(self.canvas._dragged_arrow, source_arrow)
1415
+
1416
+ # 模拟拖拽结束到C节点
1417
+ self.canvas._onArrowDragFinished(drag_pos, is_success=True)
1418
+
1419
+ # 验证:应该有两个连接:A->B 和 B->C
1420
+ # 原双向连接 A<->B 变为单向 A->B,新连接 B->C 创建
1421
+ self.assertEqual(len(self.canvas.connections), 2)
1422
+
1423
+ # 验证 B->C 连接已创建(source_arrow 起点是 B)
1424
+ b_to_c = [c for c in self.canvas.connections
1425
+ if c.sourceNode == self.nodeB and c.targetNode == self.nodeC]
1426
+ self.assertEqual(len(b_to_c), 1)
1427
+
1428
+
1429
+ class TestConnectionTypes(unittest.TestCase):
1430
+ """测试不同连接类型"""
1431
+
1432
+ @classmethod
1433
+ def setUpClass(cls) -> None:
1434
+ """设置测试类"""
1435
+ cls.app = QApplication.instance()
1436
+ if cls.app is None:
1437
+ cls.app = QApplication(sys.argv)
1438
+
1439
+ def setUp(self) -> None:
1440
+ """设置每个测试"""
1441
+ self.scene = QGraphicsScene()
1442
+ self.source_node = QNodeItem("Source", QPointF(0, 0))
1443
+ self.target_node = QNodeItem("Target", QPointF(200, 0))
1444
+ self.scene.addItem(self.source_node)
1445
+ self.scene.addItem(self.target_node)
1446
+
1447
+ def tearDown(self) -> None:
1448
+ """清理每个测试"""
1449
+ self.scene.clear()
1450
+
1451
+ def testBezierConnection(self) -> None:
1452
+ """测试贝塞尔曲线连接"""
1453
+ connection = ConnectionItem(
1454
+ self.source_node, self.target_node,
1455
+ connection_type=ConnectionType.BEZIER
1456
+ )
1457
+ self.scene.addItem(connection)
1458
+ self.scene.addItem(connection.targetArrow)
1459
+
1460
+ self.assertEqual(connection.connectionType, ConnectionType.BEZIER)
1461
+
1462
+ connection.removeArrows()
1463
+ self.scene.removeItem(connection)
1464
+
1465
+ def testStraightConnection(self) -> None:
1466
+ """测试直线连接"""
1467
+ connection = ConnectionItem(
1468
+ self.source_node, self.target_node,
1469
+ connection_type=ConnectionType.STRAIGHT
1470
+ )
1471
+ self.scene.addItem(connection)
1472
+ self.scene.addItem(connection.targetArrow)
1473
+
1474
+ self.assertEqual(connection.connectionType, ConnectionType.STRAIGHT)
1475
+
1476
+ connection.removeArrows()
1477
+ self.scene.removeItem(connection)
1478
+
1479
+
1480
+ class TestDecorator(unittest.TestCase):
1481
+ """测试装饰器节点边框颜色"""
1482
+
1483
+ @classmethod
1484
+ def setUpClass(cls) -> None:
1485
+ """设置测试类"""
1486
+ cls.app = QApplication.instance()
1487
+ if cls.app is None:
1488
+ cls.app = QApplication(sys.argv)
1489
+
1490
+ def setUp(self) -> None:
1491
+ """设置每个测试"""
1492
+ self.scene = QGraphicsScene()
1493
+ self.node = QNodeItem("TestNode", QPointF(100, 100))
1494
+ self.scene.addItem(self.node)
1495
+
1496
+ def tearDown(self) -> None:
1497
+ """清理每个测试"""
1498
+ self.scene.clear()
1499
+
1500
+ def testDecoratorProperty(self) -> None:
1501
+ """测试装饰器属性"""
1502
+ # 初始状态应该没有装饰器
1503
+ self.assertIsNone(self.node.decorator)
1504
+
1505
+ # 设置 recursive 装饰器
1506
+ self.node.decorator = "recursive"
1507
+ self.assertEqual(self.node.decorator, "recursive")
1508
+
1509
+ # 设置 behavior 装饰器
1510
+ self.node.decorator = "behavior"
1511
+ self.assertEqual(self.node.decorator, "behavior")
1512
+
1513
+ # 清除装饰器
1514
+ self.node.decorator = None
1515
+ self.assertIsNone(self.node.decorator)
1516
+
1517
+ def testRecursiveDecoratorColor(self) -> None:
1518
+ """测试 recursive 装饰器返回紫罗兰色"""
1519
+ self.node.decorator = "recursive"
1520
+ color = self.node._getDecoratorStrokeColor()
1521
+
1522
+ self.assertIsNotNone(color)
1523
+ # 紫罗兰色: RGB(238, 130, 238)
1524
+ self.assertEqual(color.red(), 238)
1525
+ self.assertEqual(color.green(), 130)
1526
+ self.assertEqual(color.blue(), 238)
1527
+
1528
+ def testBehaviorDecoratorColor(self) -> None:
1529
+ """测试 behavior 装饰器返回皇家蓝色"""
1530
+ self.node.decorator = "behavior"
1531
+ color = self.node._getDecoratorStrokeColor()
1532
+
1533
+ self.assertIsNotNone(color)
1534
+ # 皇家蓝色: RGB(65, 105, 225)
1535
+ self.assertEqual(color.red(), 65)
1536
+ self.assertEqual(color.green(), 105)
1537
+ self.assertEqual(color.blue(), 225)
1538
+
1539
+ def testSequenceDecoratorColor(self) -> None:
1540
+ """测试 sequence 装饰器返回皇家蓝色"""
1541
+ self.node.decorator = "sequence"
1542
+ color = self.node._getDecoratorStrokeColor()
1543
+ self.assertIsNotNone(color)
1544
+
1545
+ def testSelectorDecoratorColor(self) -> None:
1546
+ """测试 selector 装饰器返回皇家蓝色"""
1547
+ self.node.decorator = "selector"
1548
+ color = self.node._getDecoratorStrokeColor()
1549
+ self.assertIsNotNone(color)
1550
+
1551
+ def testParallelDecoratorColor(self) -> None:
1552
+ """测试 parallel 装饰器返回皇家蓝色"""
1553
+ self.node.decorator = "parallel"
1554
+ color = self.node._getDecoratorStrokeColor()
1555
+ self.assertIsNotNone(color)
1556
+
1557
+ def testNoDecoratorReturnsNone(self) -> None:
1558
+ """测试无装饰器时返回 None"""
1559
+ color = self.node._getDecoratorStrokeColor()
1560
+ self.assertIsNone(color)
1561
+
1562
+ def testUnknownDecoratorReturnsNone(self) -> None:
1563
+ """测试未知装饰器返回 None"""
1564
+ self.node.decorator = "unknown"
1565
+ color = self.node._getDecoratorStrokeColor()
1566
+ self.assertIsNone(color)
1567
+
1568
+ def testDecoratorCaseInsensitive(self) -> None:
1569
+ """测试装饰器类型不区分大小写"""
1570
+ # 大写的 RECURSIVE
1571
+ self.node.decorator = "RECURSIVE"
1572
+ color = self.node._getDecoratorStrokeColor()
1573
+ self.assertIsNotNone(color)
1574
+
1575
+ # 混合大小写的 Behavior
1576
+ self.node.decorator = "Behavior"
1577
+ color = self.node._getDecoratorStrokeColor()
1578
+ self.assertIsNotNone(color)
1579
+
1580
+
1581
+ class TestPorts(unittest.TestCase):
1582
+ """测试节点端口(ports)属性字段"""
1583
+
1584
+ @classmethod
1585
+ def setUpClass(cls) -> None:
1586
+ cls.app = QApplication.instance()
1587
+ if cls.app is None:
1588
+ cls.app = QApplication(sys.argv)
1589
+
1590
+ def setUp(self) -> None:
1591
+ self.scene = QGraphicsScene()
1592
+ self.node = QNodeItem("Counter", QPointF(0, 0), NodeShape.RECTANGLE)
1593
+ self.scene.addItem(self.node)
1594
+
1595
+ def tearDown(self) -> None:
1596
+ self.scene.clear()
1597
+
1598
+ def _assignSamplePorts(self) -> None:
1599
+ self.node.ports = {
1600
+ "left": [
1601
+ QNodePortSpec(name="clk", shape="triangle", size=10, hit="1"),
1602
+ QNodePortSpec(name="data", shape="trapezoid", size=12, hit="16",
1603
+ label="data[15:0]"),
1604
+ ],
1605
+ "right": [
1606
+ QNodePortSpec(name="out", shape="circle", size=10, hit="1"),
1607
+ QNodePortSpec(name="flag", shape="square", size=10),
1608
+ ],
1609
+ }
1610
+
1611
+ def testPortsDefaultEmpty(self) -> None:
1612
+ """默认 ports 为空字典(left/right)"""
1613
+ self.assertEqual(self.node.ports, {"left": [], "right": []})
1614
+
1615
+ def testPortsAssignmentBuildsItems(self) -> None:
1616
+ """赋值后按 left/right 构建渲染项"""
1617
+ self._assignSamplePorts()
1618
+ self.assertEqual(len(self.node._port_items["left"]), 2)
1619
+ self.assertEqual(len(self.node._port_items["right"]), 2)
1620
+
1621
+ def testPortsPositionedOnEdges(self) -> None:
1622
+ """端口锚点落在节点左右边缘上"""
1623
+ self._assignSamplePorts()
1624
+ rect = self.node.rect()
1625
+ for it in self.node._port_items["left"]:
1626
+ self.assertAlmostEqual(it.pos().x(), rect.left())
1627
+ for it in self.node._port_items["right"]:
1628
+ self.assertAlmostEqual(it.pos().x(), rect.right())
1629
+
1630
+ def testLabelDefaultsToName(self) -> None:
1631
+ """label 未指定时默认等同 name"""
1632
+ self._assignSamplePorts()
1633
+ clk = self.node._port_items["left"][0]
1634
+ self.assertIsNotNone(clk._label_item)
1635
+ self.assertEqual(clk._label_item.toPlainText(), "clk")
1636
+ # 默认隐藏
1637
+ self.assertFalse(clk._label_item.isVisible())
1638
+
1639
+ def testLabelExplicitValue(self) -> None:
1640
+ """显式 label 优先于 name"""
1641
+ self._assignSamplePorts()
1642
+ data = self.node._port_items["left"][1]
1643
+ self.assertEqual(data._label_item.toPlainText(), "data[15:0]")
1644
+
1645
+ def testShowAllLabelsToggle(self) -> None:
1646
+ """节点级显示/隐藏全部 label(断言逻辑目标态; 实际可见受动画时序影响)"""
1647
+ self._assignSamplePorts()
1648
+ self.node._setAllPortsShowAll(True)
1649
+ for it in self.node._port_items["left"] + self.node._port_items["right"]:
1650
+ # 显示是立即 show(), item 可见且逻辑态为真
1651
+ self.assertTrue(it._label_item.isVisible())
1652
+ self.assertTrue(it._label_visible)
1653
+ self.node._setAllPortsShowAll(False)
1654
+ for it in self.node._port_items["left"] + self.node._port_items["right"]:
1655
+ # 隐藏走淡出动画, 逻辑态立即翻转(动画结束后才真正 hide)
1656
+ self.assertFalse(it._label_visible)
1657
+
1658
+ def testDotHoverShowsOnlyThatDot(self) -> None:
1659
+ """悬停某个 dot 时只显示该 dot 的 label, 不触发显示全部
1660
+
1661
+ 断言逻辑目标态(_label_visible); 实际可见性受动画时序影响。
1662
+ """
1663
+ self._assignSamplePorts()
1664
+ clk = self.node._port_items["left"][0]
1665
+ other = self.node._port_items["right"][0]
1666
+ # 模拟节点已 hover 且 show-all 已开启
1667
+ self.node._node_hovered = True
1668
+ self.node._setAllPortsShowAll(True)
1669
+ self.assertTrue(other._label_visible)
1670
+ # 悬停 clk -> 只剩 clk
1671
+ clk._hovered = True
1672
+ clk._solo = True
1673
+ clk._updateLabelVisible()
1674
+ self.node._onPortHoverEnter(clk)
1675
+ self.assertTrue(clk._label_visible) # solo
1676
+ self.assertFalse(other._label_visible) # 被收起
1677
+ # 离开 clk -> 计时恢复(此处直接验证计数回落)
1678
+ clk._hovered = False
1679
+ clk._solo = False
1680
+ clk._updateLabelVisible()
1681
+ self.node._onPortHoverLeave(clk)
1682
+ self.assertEqual(self.node._hovered_port_count, 0)
1683
+
1684
+ def testHitTextCreated(self) -> None:
1685
+ """hit 文本被创建"""
1686
+ self._assignSamplePorts()
1687
+ self.assertIsNotNone(self.node._port_items["left"][0]._hit_item)
1688
+
1689
+ def testInvalidShapeRejected(self) -> None:
1690
+ """非法 shape 抛出 ValueError"""
1691
+ with self.assertRaises(ValueError):
1692
+ self.node.ports = {"left": [QNodePortSpec(name="x", shape="hexagon")],
1693
+ "right": []}
1694
+
1695
+ def testDictCoercion(self) -> None:
1696
+ """dict 元素自动转换为 QNodePortSpec"""
1697
+ self.node.ports = {"left": [{"name": "d", "shape": "circle",
1698
+ "size": 8, "hit": "2"}],
1699
+ "right": []}
1700
+ self.assertIsInstance(self.node.ports["left"][0], QNodePortSpec)
1701
+ self.assertEqual(self.node.ports["left"][0].name, "d")
1702
+
1703
+ def testRoundTrip(self) -> None:
1704
+ """toDict/fromDict 端口往返"""
1705
+ self._assignSamplePorts()
1706
+ data = self.node.toDict()
1707
+ self.assertIn("ports", data)
1708
+ other = QNodeItem("X", QPointF(0, 0))
1709
+ other.fromDict(data)
1710
+ self.assertEqual(len(other.ports["left"]), 2)
1711
+ self.assertEqual(other.ports["left"][0].name, "clk")
1712
+
1713
+ def testReassignTeardown(self) -> None:
1714
+ """重新赋值会移除旧端口项"""
1715
+ self._assignSamplePorts()
1716
+ self.node.ports = {"left": [], "right": []}
1717
+ self.assertEqual(self.node._port_items["left"], [])
1718
+ self.assertEqual(self.node._port_items["right"], [])
1719
+
1720
+ def testClicksPassThrough(self) -> None:
1721
+ """端口不拦截鼠标按键(点击穿透回节点)"""
1722
+ from PyQt6.QtCore import Qt
1723
+ self._assignSamplePorts()
1724
+ for it in self.node._port_items["left"] + self.node._port_items["right"]:
1725
+ self.assertEqual(it.acceptedMouseButtons(), Qt.MouseButton.NoButton)
1726
+
1727
+ def testPortsRelayoutOnRadiusChange(self) -> None:
1728
+ """节点改半径后端口锚点跟随新边缘"""
1729
+ self._assignSamplePorts()
1730
+ old_left_x = self.node._port_items["left"][0].pos().x()
1731
+ self.node.setRadius(self.node.radius * 2)
1732
+ new_left_x = self.node._port_items["left"][0].pos().x()
1733
+ self.assertNotAlmostEqual(old_left_x, new_left_x)
1734
+ self.assertAlmostEqual(new_left_x, self.node.rect().left())
1735
+
1736
+ def testFromDictWithCustomRadiusRelayoutsPorts(self) -> None:
1737
+ """fromDict 同时加载端口与自定义半径时端口位置正确"""
1738
+ self._assignSamplePorts()
1739
+ data = self.node.toDict()
1740
+ data.setdefault("style", {})["radius"] = self.node.radius * 2.5
1741
+ other = QNodeItem("Y", QPointF(0, 0), NodeShape.RECTANGLE)
1742
+ other.fromDict(data)
1743
+ for it in other._port_items["left"]:
1744
+ self.assertAlmostEqual(it.pos().x(), other.rect().left())
1745
+
1746
+ def testRectangleWithPortsNonSquareAndPacked(self) -> None:
1747
+ """矩形+多端口: 长宽可不等; 端口紧凑不重叠(>=5px), 不压圆角"""
1748
+ self.node.ports = {
1749
+ "left": [QNodePortSpec(name=f"in{i}", shape="trapezoid", size=14,
1750
+ hit="") for i in range(5)],
1751
+ "right": [],
1752
+ }
1753
+ rect = self.node.rect()
1754
+ half_h = rect.height() / 2
1755
+ corner = min(rect.width(), rect.height()) * 0.2
1756
+
1757
+ # 端口很多(无 hit 撑宽) -> 高度由端口决定, 高于宽度
1758
+ self.assertGreaterEqual(rect.height(), rect.width() - 1e-6)
1759
+
1760
+ ordered = sorted(self.node._port_items["left"], key=lambda it: it.pos().y())
1761
+ # 相邻端口边缘间距 >= 5px
1762
+ for a, b in zip(ordered, ordered[1:]):
1763
+ gap = (b.pos().y() - b.spec.size / 2) - (a.pos().y() + a.spec.size / 2)
1764
+ self.assertGreaterEqual(gap, 5.0 - 1e-6)
1765
+ # 上下端口不进入圆角区域
1766
+ top_edge = ordered[0].pos().y() - ordered[0].spec.size / 2
1767
+ bot_edge = ordered[-1].pos().y() + ordered[-1].spec.size / 2
1768
+ self.assertGreaterEqual(top_edge, -(half_h - corner) - 1e-6)
1769
+ self.assertLessEqual(bot_edge, (half_h - corner) + 1e-6)
1770
+
1771
+ def testHitTextAddsWidth(self) -> None:
1772
+ """有 hit 文本时, 节点宽度比无 hit 时更大(为 hit 预留空间)"""
1773
+ base = QNodeItem("M", QPointF(0, 0), NodeShape.RECTANGLE)
1774
+ base.ports = {"left": [QNodePortSpec(name="a", shape="trapezoid",
1775
+ size=14, hit="")], "right": []}
1776
+ w_no_hit = base.rect().width()
1777
+
1778
+ with_hit = QNodeItem("M", QPointF(0, 0), NodeShape.RECTANGLE)
1779
+ with_hit.ports = {"left": [QNodePortSpec(name="a", shape="trapezoid",
1780
+ size=14, hit="16")], "right": []}
1781
+ w_hit = with_hit.rect().width()
1782
+ self.assertGreater(w_hit, w_no_hit)
1783
+
1784
+ def testHitCloserThanLabel(self) -> None:
1785
+ """hit 紧贴 dot(间距小), label 在外侧(间距大)"""
1786
+ self._assignSamplePorts()
1787
+ it = self.node._port_items["left"][0] # left: dot 在边界, 向内 +x
1788
+ depth = it._depth()
1789
+ # hit 在 dot 内侧, 距边界 depth + HIT_GAP
1790
+ hit_x = it._hit_item.pos().x()
1791
+ from chartflow.node.port_item import HIT_GAP, LABEL_GAP
1792
+ self.assertAlmostEqual(hit_x, depth + HIT_GAP, delta=1e-6)
1793
+ self.assertLess(HIT_GAP, LABEL_GAP)
1794
+
1795
+ def testEllipseAlwaysPerfectCircle(self) -> None:
1796
+ """椭圆(圆形)节点固定为标准圆: 任何尺寸操作后宽高相等"""
1797
+ node = QNodeItem("DSP", QPointF(0, 0), NodeShape.ELLIPSE)
1798
+ self.scene.addItem(node)
1799
+ node.ports = {
1800
+ "left": [QNodePortSpec(name="a", shape="triangle", size=10, hit="1"),
1801
+ QNodePortSpec(name="b", shape="trapezoid", size=14, hit="16")],
1802
+ "right": [QNodePortSpec(name="o", shape="circle", size=12, hit="8")],
1803
+ }
1804
+ r = node.rect()
1805
+ self.assertEqual(r.width(), r.height())
1806
+
1807
+ # 即使强行传入不等宽高, 也被校正为圆
1808
+ node._setSize(120.0, 60.0)
1809
+ r = node.rect()
1810
+ self.assertEqual(r.width(), r.height())
1811
+ self.assertAlmostEqual(r.width(), 240.0) # 取较大者 -> 直径 240
1812
+
1813
+ # setRadius 后仍为圆
1814
+ node.setRadius(70)
1815
+ r = node.rect()
1816
+ self.assertEqual(r.width(), r.height())
1817
+ self.assertAlmostEqual(r.width(), 140.0)
1818
+
1819
+ # 改名/改字号后仍为圆
1820
+ node.name = "a very long node name"
1821
+ node.setFont(size=16)
1822
+ r = node.rect()
1823
+ self.assertEqual(r.width(), r.height())
1824
+
1825
+ def testRectangleToEllipseBecomesCircle(self) -> None:
1826
+ """矩形(带端口, 非方形)切换为椭圆后, 本体被校正为标准圆"""
1827
+ node = QNodeItem("DSP", QPointF(0, 0), NodeShape.RECTANGLE)
1828
+ self.scene.addItem(node)
1829
+ node.ports = {
1830
+ "left": [QNodePortSpec(name=f"d{i}", shape="trapezoid", size=14, hit="16")
1831
+ for i in range(6)],
1832
+ "right": [],
1833
+ }
1834
+ # 矩形带端口 -> 非方形
1835
+ self.assertNotEqual(node.rect().width(), node.rect().height())
1836
+ # 切换为椭圆 -> 标准圆, 本体/选中区与环一致
1837
+ node.nodeShape = NodeShape.ELLIPSE
1838
+ r = node.rect()
1839
+ self.assertEqual(r.width(), r.height())
1840
+ # 端口变为径向
1841
+ for it in node._port_items["left"]:
1842
+ self.assertTrue(it._radial)
1843
+
1844
+ def testCirclePortsRadialOnArc(self) -> None:
1845
+ """圆形节点: dot 落在圆上, apex 对准圆心, 分布在侧弧窗口内(避开极点), label 水平"""
1846
+ import math
1847
+ node = QNodeItem("circ", QPointF(0, 0), NodeShape.ELLIPSE)
1848
+ self.scene.addItem(node)
1849
+ node.ports = {
1850
+ "left": [QNodePortSpec(name=f"L{i}", shape="trapezoid", size=12, hit="16")
1851
+ for i in range(4)],
1852
+ "right": [QNodePortSpec(name=f"R{i}", shape="triangle", size=10, hit="1")
1853
+ for i in range(3)],
1854
+ }
1855
+ R = node.rect().width() / 2.0
1856
+ c = node.rect().center()
1857
+ from chartflow.node.node_item import CIRCLE_PORT_WINDOW_DEG
1858
+ for side in ("left", "right"):
1859
+ for it in node._port_items[side]:
1860
+ self.assertTrue(it._radial)
1861
+ p = it.pos()
1862
+ d = math.hypot(p.x() - c.x(), p.y() - c.y())
1863
+ self.assertAlmostEqual(d, R, delta=1e-3) # 在圆上
1864
+ ang = math.degrees(math.atan2(p.y() - c.y(), p.x() - c.x()))
1865
+ center = 180.0 if side == "left" else 0.0
1866
+ # 折算到 (-180,180], 距侧中心不超过窗口
1867
+ off = (ang - center + 180.0) % 360.0 - 180.0
1868
+ self.assertLessEqual(abs(off), CIRCLE_PORT_WINDOW_DEG + 1e-6)
1869
+ # apex 方向应等于朝心方向
1870
+ oa = math.radians(it._orient_angle)
1871
+ apex = (math.cos(oa), math.sin(oa))
1872
+ inward = (-(p.x() - c.x()) / d, -(p.y() - c.y()) / d)
1873
+ self.assertAlmostEqual(apex[0], inward[0], delta=1e-6)
1874
+ self.assertAlmostEqual(apex[1], inward[1], delta=1e-6)
1875
+ # label 文本保持水平
1876
+ self.assertEqual(it._label_item.rotation(), 0.0)
1877
+
1878
+ def testDiamondPortsOnFacet(self) -> None:
1879
+ """菱形: dot 贴在左/右斜边上, _orient_angle = 该斜边内法线(垂直于斜边,
1880
+ 指向节点内部), 使旋转后底边贴合斜边; label 水平"""
1881
+ import math
1882
+ node = QNodeItem("dia", QPointF(0, 0), NodeShape.DIAMOND)
1883
+ self.scene.addItem(node)
1884
+ node.ports = {
1885
+ "left": [QNodePortSpec(name=f"L{i}", shape="trapezoid", size=12, hit="16")
1886
+ for i in range(4)],
1887
+ "right": [QNodePortSpec(name=f"R{i}", shape="triangle", size=10, hit="1")
1888
+ for i in range(3)],
1889
+ }
1890
+ rect = node.rect()
1891
+ hw = rect.width() / 2.0
1892
+ hh = rect.height() / 2.0
1893
+ c = rect.center()
1894
+ for side in ("left", "right"):
1895
+ sgn = -1.0 if side == "left" else 1.0
1896
+ for it in node._port_items[side]:
1897
+ self.assertTrue(it._diamond)
1898
+ self.assertFalse(it._radial)
1899
+ p = it.pos()
1900
+ # 1) 点必须在菱形斜边上: |x/hw| + |y/hh| == 1(在边界上)
1901
+ denom_x = hw if hw > 0 else 1.0
1902
+ denom_y = hh if hh > 0 else 1.0
1903
+ boundary = abs(p.x() - c.x()) / denom_x + abs(p.y() - c.y()) / denom_y
1904
+ self.assertAlmostEqual(boundary, 1.0, delta=1e-3)
1905
+ # 2) 点位于正确侧: x 与 sgn 同号(右端口 x>0, 左端口 x<0)
1906
+ self.assertGreater((p.x() - c.x()) * sgn, -1e-6)
1907
+ # 3) _orient_angle 必须等于该点所在斜边的内法线(垂直于斜边)。
1908
+ # 斜边内法线是常量: 由斜边方向旋转 90° 得到。
1909
+ # 上半斜边(顶点->侧顶点): 右侧方向(hw,hh)内法线(-hh,hw);
1910
+ # 左侧方向(-hw,hh)内法线(hh,hw)
1911
+ # 下半斜边(侧顶点->底顶点): 右侧方向(-hw,hh)内法线(-hh,-hw);
1912
+ # 左侧方向(hw,hh)内法线(hh,-hw)
1913
+ upper = (p.y() - c.y()) < 0
1914
+ if side == "right":
1915
+ ix, iy = (-hh, hw) if upper else (-hh, -hw)
1916
+ else:
1917
+ ix, iy = (hh, hw) if upper else (hh, -hw)
1918
+ nl = math.hypot(ix, iy)
1919
+ expected_ang = math.degrees(math.atan2(iy / nl, ix / nl))
1920
+ oa = it._orient_angle % 360.0
1921
+ ea = expected_ang % 360.0
1922
+ diff = min(abs(oa - ea), 360.0 - abs(oa - ea))
1923
+ self.assertLess(diff, 1e-3)
1924
+ # 4) 验证旋转后底边确实平行于斜边: 底边原为垂直(x=0), 旋转
1925
+ # _orient_angle 后方向 = (sin(ang), cos(ang)*-1)... 用法线检验:
1926
+ # dot 底边法线应平行于斜边内法线(即 _orient_angle 方向)
1927
+ # 5) label 文本保持水平
1928
+ self.assertEqual(it._label_item.rotation(), 0.0)
1929
+ # 6) 所有端口都避开四个顶点: 距任一顶点 >= vertex_gap
1930
+ vg = node._diamondVertexGap()
1931
+ vertices = [QPointF(c.x(), c.y() - hh), QPointF(c.x() + hw, c.y()),
1932
+ QPointF(c.x(), c.y() + hh), QPointF(c.x() - hw, c.y())]
1933
+ for side in ("left", "right"):
1934
+ for it in node._port_items[side]:
1935
+ p = it.pos()
1936
+ for v in vertices:
1937
+ self.assertGreaterEqual(
1938
+ math.hypot(p.x() - v.x(), p.y() - v.y()), vg - 1e-6,
1939
+ f"端口 {it.spec.name} 过于靠近顶点 {v}")
1940
+
1941
+ def testDiamondPortsClusterTowardSide(self) -> None:
1942
+ """菱形端口偏向侧顶点(左右两角): 端口组中心比物理中点更靠近侧顶点"""
1943
+ import math
1944
+ node = QNodeItem("dia", QPointF(0, 0), NodeShape.DIAMOND)
1945
+ self.scene.addItem(node)
1946
+ node.ports = {
1947
+ "left": [QNodePortSpec(name=f"L{i}", shape="triangle", size=10, hit="1")
1948
+ for i in range(2)],
1949
+ "right": [QNodePortSpec(name=f"R{i}", shape="triangle", size=10, hit="1")
1950
+ for i in range(2)],
1951
+ }
1952
+ c = node.rect().center()
1953
+ for side in ("left", "right"):
1954
+ sgn = -1.0 if side == "left" else 1.0
1955
+ # 端口应偏向侧顶点: |x| 较大(靠近侧顶点 hw), 而非靠近垂直中线
1956
+ for it in node._port_items[side]:
1957
+ self.assertGreater(it.pos().x() * sgn, 1.0,
1958
+ f"{side} 端口应偏向侧顶点而非中线")
1959
+
1960
+ def testDiamondPortEmissionDirection(self) -> None:
1961
+ """菱形端口连线出射方向 = 该端口所在斜边的外法线(单位向量, 指向节点外)"""
1962
+ import math
1963
+ node = QNodeItem("dia", QPointF(0, 0), NodeShape.DIAMOND)
1964
+ self.scene.addItem(node)
1965
+ # 多端口: 保证每个端口明确落在某一条斜边上(非赤道顶点)
1966
+ node.ports = {
1967
+ "left": [QNodePortSpec(name=f"L{i}", shape="triangle", size=10, hit="1")
1968
+ for i in range(2)],
1969
+ "right": [QNodePortSpec(name=f"R{i}", shape="triangle", size=10, hit="1")
1970
+ for i in range(2)],
1971
+ }
1972
+ rect = node.rect()
1973
+ hw = rect.width() / 2.0
1974
+ hh = rect.height() / 2.0
1975
+ c = rect.center()
1976
+ conn = ConnectionItem(node, node) # 临时对象, 仅用于调用方法
1977
+ for side in ("left", "right"):
1978
+ for it in node._port_items[side]:
1979
+ d = conn._portRadialDirection(it)
1980
+ self.assertIsNotNone(d)
1981
+ # 单位向量
1982
+ self.assertAlmostEqual(math.hypot(d.x(), d.y()), 1.0, delta=1e-6)
1983
+ # 外法线 = _orient_angle(内法线)的反方向
1984
+ oa = math.radians(it._orient_angle)
1985
+ expected = QPointF(-math.cos(oa), -math.sin(oa))
1986
+ self.assertAlmostEqual(d.x(), expected.x(), delta=1e-3)
1987
+ self.assertAlmostEqual(d.y(), expected.y(), delta=1e-3)
1988
+ # 外法线应指向节点外: 与(点->外)同向, 即外法线在 x 方向的符号
1989
+ # 应与"点相对中心"的 x 符号一致
1990
+ p = it.pos()
1991
+ self.assertEqual(d.x() > 0, (p.x() - c.x()) > 0)
1992
+
1993
+ def testRectanglePortEmissionPerpendicularToEdge(self) -> None:
1994
+ """矩形端口连线出射方向恒为水平(垂直于左/右边缘), 不随目标位置变化"""
1995
+ import math
1996
+ node = QNodeItem("rect", QPointF(0, 0), NodeShape.RECTANGLE)
1997
+ self.scene.addItem(node)
1998
+ node.ports = {
1999
+ "left": [QNodePortSpec(name="L0", shape="triangle", size=10, hit="1")],
2000
+ "right": [QNodePortSpec(name="R0", shape="triangle", size=10, hit="1")],
2001
+ }
2002
+ conn = ConnectionItem(node, node) # 临时对象, 仅用于调用方法
2003
+ # right port -> +x(水平向右, 垂直于右边缘)
2004
+ d = conn._portRadialDirection(node._port_items["right"][0])
2005
+ self.assertIsNotNone(d)
2006
+ self.assertAlmostEqual(d.x(), 1.0, delta=1e-6)
2007
+ self.assertAlmostEqual(d.y(), 0.0, delta=1e-6)
2008
+ # left port -> -x(水平向左, 垂直于左边缘)
2009
+ d = conn._portRadialDirection(node._port_items["left"][0])
2010
+ self.assertIsNotNone(d)
2011
+ self.assertAlmostEqual(d.x(), -1.0, delta=1e-6)
2012
+ self.assertAlmostEqual(d.y(), 0.0, delta=1e-6)
2013
+
2014
+ def testShowAllRequiresStillness(self) -> None:
2015
+ """显示全部端口要求鼠标静止: 移动超过阈值会打断并重置等待
2016
+
2017
+ 断言逻辑目标态(_label_visible); 实际可见性受动画时序影响。
2018
+ """
2019
+ self._assignSamplePorts()
2020
+ other = self.node._port_items["left"][1]
2021
+
2022
+ # 模拟已 hover 节点本体: 进入等待, 锚点在 (0,0)
2023
+ self.node._node_hovered = True
2024
+ self.node._hover_anchor = QPointF(0, 0)
2025
+ self.node._maybeStartPortShowAll()
2026
+ self.assertTrue(self.node._port_label_timer.isActive())
2027
+
2028
+ # 假设已显示全部 label
2029
+ self.node._setAllPortsShowAll(True)
2030
+ self.assertTrue(other._label_visible)
2031
+
2032
+ # 大幅移动(>5px) -> 打断: 隐藏全部 + 重新计时 + 更新锚点
2033
+ self.node._handleHoverAnchorMove(QPointF(100, 100))
2034
+ self.assertFalse(other._label_visible)
2035
+ self.assertTrue(self.node._port_label_timer.isActive())
2036
+ self.assertEqual(self.node._hover_anchor, QPointF(100, 100))
2037
+
2038
+ # 小幅移动(<5px) -> 不重置, label 保持
2039
+ self.node._setAllPortsShowAll(True)
2040
+ self.node._handleHoverAnchorMove(QPointF(102, 101))
2041
+ self.assertTrue(other._label_visible)
2042
+
2043
+ def testNameFitsInnerRegion(self) -> None:
2044
+ """名称容纳在内部区域(INNER_RATIO 以内)"""
2045
+ from chartflow.node.node_item import INNER_RATIO
2046
+ rect = self.node.rect()
2047
+ text_rect = self.node._text_item.boundingRect()
2048
+ inner_half_w = rect.width() / 2 * INNER_RATIO
2049
+ # 名称半宽 <= 内部区域半宽
2050
+ self.assertLessEqual(text_rect.width() / 2, inner_half_w + 1e-6)
2051
+
2052
+ def testFillColorRespectsCustomColor(self) -> None:
2053
+ """_fill_color 优先使用 port.color"""
2054
+ red = QColor(255, 0, 0)
2055
+ self.node.ports = {
2056
+ "left": [QNodePortSpec(name="a", color=red)],
2057
+ "right": [QNodePortSpec(name="b", color=red)],
2058
+ }
2059
+ self.assertEqual(self.node._port_items["left"][0]._fill_color(), red)
2060
+ self.assertEqual(self.node._port_items["right"][0]._fill_color(), red)
2061
+
2062
+ def testFillColorFallsBackToSideDefault(self) -> None:
2063
+ """color=None 时按所在侧使用 LEFT_COLOR / RIGHT_COLOR"""
2064
+ self.node.ports = {
2065
+ "left": [QNodePortSpec(name="a")],
2066
+ "right": [QNodePortSpec(name="b")],
2067
+ }
2068
+ self.assertEqual(self.node._port_items["left"][0]._fill_color(),
2069
+ QNodePortSpec.LEFT_COLOR)
2070
+ self.assertEqual(self.node._port_items["right"][0]._fill_color(),
2071
+ QNodePortSpec.RIGHT_COLOR)
2072
+
2073
+ def testFillColorFollowsLeftColorOverride(self) -> None:
2074
+ """改写 QNodePortSpec.LEFT_COLOR/RIGHT_COLOR 后, 默认色跟随变化"""
2075
+ original = (QColor(QNodePortSpec.LEFT_COLOR), QColor(QNodePortSpec.RIGHT_COLOR))
2076
+ try:
2077
+ new_left = QColor("#123456")
2078
+ new_right = QColor("#654321")
2079
+ QNodePortSpec.LEFT_COLOR = new_left
2080
+ QNodePortSpec.RIGHT_COLOR = new_right
2081
+ self.node.ports = {
2082
+ "left": [QNodePortSpec(name="a")],
2083
+ "right": [QNodePortSpec(name="b")],
2084
+ }
2085
+ self.assertEqual(self.node._port_items["left"][0]._fill_color(), new_left)
2086
+ self.assertEqual(self.node._port_items["right"][0]._fill_color(), new_right)
2087
+ finally:
2088
+ QNodePortSpec.LEFT_COLOR, QNodePortSpec.RIGHT_COLOR = original
2089
+
2090
+ def testPortColorActuallyRendered(self) -> None:
2091
+ """端到端: 自定义色端口经 paint 后, 像素确实呈现该颜色"""
2092
+ from PyQt6.QtWidgets import QGraphicsView
2093
+ # 用一个不易与节点本体/背景混淆的紫色
2094
+ purple = QColor("#9B30FF")
2095
+ self.node.ports = {
2096
+ "left": [QNodePortSpec(name="a", shape="circle", size=14, color=purple)],
2097
+ "right": [],
2098
+ }
2099
+ # 校验端口确实使用自定义色(逻辑层)
2100
+ self.assertEqual(self.node._port_items["left"][0]._fill_color(), purple)
2101
+
2102
+ scene = self.node.scene()
2103
+ self.assertIsNotNone(scene)
2104
+ # 通过 view.grab 光栅化(对半圆 dot 的抗锯齿更稳定)
2105
+ view = QGraphicsView(scene)
2106
+ view.resize(300, 300)
2107
+ view.show()
2108
+ self.app.processEvents()
2109
+ img = view.grab().toImage()
2110
+
2111
+ # 在渲染图中应能找到接近紫色的像素(抗锯齿混色 -> 容差 60)
2112
+ found = 0
2113
+ for y in range(img.height()):
2114
+ for x in range(img.width()):
2115
+ c = img.pixelColor(x, y)
2116
+ if (abs(c.red() - purple.red()) < 60
2117
+ and abs(c.green() - purple.green()) < 60
2118
+ and abs(c.blue() - purple.blue()) < 60):
2119
+ found += 1
2120
+ self.assertGreater(found, 0, "渲染图中未找到端口自定义色像素")
2121
+
2122
+
2123
+ class TestColorUtils(unittest.TestCase):
2124
+ """测试颜色感知亮度工具(连线端口渐变用)"""
2125
+
2126
+ @classmethod
2127
+ def setUpClass(cls) -> None:
2128
+ cls.app = QApplication.instance()
2129
+ if cls.app is None:
2130
+ cls.app = QApplication(sys.argv)
2131
+
2132
+ def testRelativeLuminanceBounds(self) -> None:
2133
+ """相对亮度在 [0,1], 黑=0 白=1"""
2134
+ from chartflow.node.color_utils import relative_luminance
2135
+ self.assertAlmostEqual(relative_luminance(QColor("#000000")), 0.0, places=6)
2136
+ self.assertAlmostEqual(relative_luminance(QColor("#FFFFFF")), 1.0, places=6)
2137
+
2138
+ def testConstantMatchesConnectionGrey(self) -> None:
2139
+ """PORT_GRADIENT_NEUTRAL_LUMINANCE 取自默认连线灰 #8B93A1"""
2140
+ from chartflow.node.color_utils import (
2141
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, relative_luminance,
2142
+ )
2143
+ grey = QColor("#8B93A1") # DEFAULT_CONNECTION_NORMAL
2144
+ self.assertAlmostEqual(
2145
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, relative_luminance(grey),
2146
+ places=3,
2147
+ )
2148
+
2149
+ def testAdjustToRelativeLuminanceConverges(self) -> None:
2150
+ """任意颜色调整到目标亮度后, 相对亮度近似等于目标(8bit 量化误差内)"""
2151
+ from chartflow.node.color_utils import (
2152
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, color_at_relative_luminance,
2153
+ relative_luminance,
2154
+ )
2155
+ for hexc in ("#1E7C5A", "#EE7942", "#1E90FF", "#9AA1AB", "#FF0000"):
2156
+ adj = color_at_relative_luminance(QColor(hexc), PORT_GRADIENT_NEUTRAL_LUMINANCE)
2157
+ self.assertLess(
2158
+ abs(relative_luminance(adj) - PORT_GRADIENT_NEUTRAL_LUMINANCE),
2159
+ 0.01, # 8bit 量化误差
2160
+ f"{hexc} 调整后亮度未收敛到 L0",
2161
+ )
2162
+
2163
+ def testAdjustPreservesHue(self) -> None:
2164
+ """调整亮度尽量保留色相: 红/绿/蓝调整后主通道仍占主导"""
2165
+ from chartflow.node.color_utils import (
2166
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, color_at_relative_luminance,
2167
+ )
2168
+ # 纯红: R 仍应是最大通道
2169
+ adj_r = color_at_relative_luminance(QColor("#FF0000"), PORT_GRADIENT_NEUTRAL_LUMINANCE)
2170
+ self.assertGreater(adj_r.red(), adj_r.green())
2171
+ self.assertGreater(adj_r.red(), adj_r.blue())
2172
+ # 纯绿: G 最大
2173
+ adj_g = color_at_relative_luminance(QColor("#00FF00"), PORT_GRADIENT_NEUTRAL_LUMINANCE)
2174
+ self.assertGreater(adj_g.green(), adj_g.red())
2175
+ self.assertGreater(adj_g.green(), adj_g.blue())
2176
+
2177
+ def testNeutralGreyIsAchromatic(self) -> None:
2178
+ """中性灰 R=G=B"""
2179
+ from chartflow.node.color_utils import (
2180
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, neutral_grey_at_relative_luminance,
2181
+ )
2182
+ g = neutral_grey_at_relative_luminance(PORT_GRADIENT_NEUTRAL_LUMINANCE)
2183
+ self.assertEqual(g.red(), g.green())
2184
+ self.assertEqual(g.green(), g.blue())
2185
+
2186
+ def testAdjustBlackFallbackToGrey(self) -> None:
2187
+ """纯黑无色相: 回退为与目标亮度等价的灰度"""
2188
+ from chartflow.node.color_utils import (
2189
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, color_at_relative_luminance,
2190
+ )
2191
+ adj = color_at_relative_luminance(QColor("#000000"), PORT_GRADIENT_NEUTRAL_LUMINANCE)
2192
+ self.assertEqual(adj.red(), adj.green())
2193
+ self.assertEqual(adj.green(), adj.blue())
2194
+
2195
+
2196
+ class TestPortGradient(unittest.TestCase):
2197
+ """测试端口渐变连线: 某端为 port 时该端为端口色(L0), 向中点过渡到中性灰"""
2198
+
2199
+ @classmethod
2200
+ def setUpClass(cls) -> None:
2201
+ cls.app = QApplication.instance()
2202
+ if cls.app is None:
2203
+ cls.app = QApplication(sys.argv)
2204
+
2205
+ def setUp(self) -> None:
2206
+ self.scene = QGraphicsScene()
2207
+ self.nodeA = QNodeItem("A", QPointF(0, 0))
2208
+ self.nodeB = QNodeItem("B", QPointF(200, 0))
2209
+ self.scene.addItem(self.nodeA)
2210
+ self.scene.addItem(self.nodeB)
2211
+
2212
+ def tearDown(self) -> None:
2213
+ self.scene.clear()
2214
+
2215
+ def _addPorts(self) -> None:
2216
+ self.nodeA.ports = {"right": [QNodePortSpec(name="out", color=QColor("#1E90FF"))]}
2217
+ self.nodeB.ports = {"left": [QNodePortSpec(name="in", color=QColor("#EE7942"))]}
2218
+
2219
+ def testNodeToNodeNoGradient(self) -> None:
2220
+ """node→node 连线不启用渐变"""
2221
+ conn = ConnectionItem(self.nodeA, self.nodeB)
2222
+ self.scene.addItem(conn)
2223
+ self.assertFalse(conn._hasPortGradient())
2224
+ self.assertIsNone(conn._buildGradientBrush())
2225
+
2226
+ def testPortConnectionHasGradient(self) -> None:
2227
+ """含 port 的连线启用渐变, 且能构造渐变画刷"""
2228
+ self._addPorts()
2229
+ src_port = self.nodeA._port_items["right"][0]
2230
+ tgt_port = self.nodeB._port_items["left"][0]
2231
+ conn = ConnectionItem(
2232
+ self.nodeA, self.nodeB, source_port=src_port, target_port=tgt_port,
2233
+ )
2234
+ self.scene.addItem(conn)
2235
+ self.assertTrue(conn._hasPortGradient())
2236
+ self.assertIsNotNone(conn._buildGradientBrush())
2237
+
2238
+ def testGradientDisabledWhenSelected(self) -> None:
2239
+ """选中态禁用渐变(使用选中色 pen)"""
2240
+ self._addPorts()
2241
+ src_port = self.nodeA._port_items["right"][0]
2242
+ conn = ConnectionItem(self.nodeA, self.nodeB, source_port=src_port)
2243
+ self.scene.addItem(conn)
2244
+ conn.setSelected(True)
2245
+ self.assertTrue(conn._is_selected)
2246
+ # paint 走渐变的条件: 非选中且非闪烁
2247
+ active = conn._hasPortGradient() and not conn._is_selected and not conn._is_flashing
2248
+ self.assertFalse(active)
2249
+ conn.setSelected(False)
2250
+ active = conn._hasPortGradient() and not conn._is_selected and not conn._is_flashing
2251
+ self.assertTrue(active)
2252
+
2253
+ def testPortColorFromSideDefault(self) -> None:
2254
+ """port.color 为 None 时取所在侧默认色(left/right), 经亮度调整后返回"""
2255
+ self.nodeA.ports = {"left": [QNodePortSpec(name="in")]} # 无自定义色 -> LEFT_COLOR
2256
+ self.nodeB.ports = {"right": [QNodePortSpec(name="out")]}
2257
+ src_port = self.nodeA._port_items["left"][0]
2258
+ tgt_port = self.nodeB._port_items["right"][0]
2259
+ conn = ConnectionItem(
2260
+ self.nodeA, self.nodeB, source_port=src_port, target_port=tgt_port,
2261
+ )
2262
+ self.scene.addItem(conn)
2263
+ from chartflow.node.port_item import QNodePortSpec as _QP
2264
+ from chartflow.node.color_utils import (
2265
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, color_at_relative_luminance,
2266
+ relative_luminance,
2267
+ )
2268
+ # 返回值是 LEFT/RIGHT_COLOR 调整到 L0 后的颜色(亮度一致, 色相保留)
2269
+ src_adj = conn._portSideColor(True)
2270
+ # 色相保留: 绿(left)的绿色通道应最大
2271
+ self.assertGreaterEqual(src_adj.green(), src_adj.red())
2272
+ self.assertGreaterEqual(src_adj.green(), src_adj.blue())
2273
+ # 亮度一致
2274
+ self.assertLess(
2275
+ abs(relative_luminance(src_adj) - PORT_GRADIENT_NEUTRAL_LUMINANCE), 0.01
2276
+ )
2277
+ # 与直接对 LEFT_COLOR 调整的结果应一致
2278
+ self.assertEqual(
2279
+ src_adj.name(),
2280
+ color_at_relative_luminance(_QP.LEFT_COLOR, PORT_GRADIENT_NEUTRAL_LUMINANCE).name(),
2281
+ )
2282
+ tgt_adj = conn._portSideColor(False)
2283
+ self.assertEqual(
2284
+ tgt_adj.name(),
2285
+ color_at_relative_luminance(_QP.RIGHT_COLOR, PORT_GRADIENT_NEUTRAL_LUMINANCE).name(),
2286
+ )
2287
+
2288
+ def testPortColorAdjustedToNeutralLuminance(self) -> None:
2289
+ """端口侧颜色已调整到 PORT_GRADIENT_NEUTRAL_LUMINANCE(亮度一致)"""
2290
+ self._addPorts()
2291
+ src_port = self.nodeA._port_items["right"][0]
2292
+ conn = ConnectionItem(self.nodeA, self.nodeB, source_port=src_port)
2293
+ self.scene.addItem(conn)
2294
+ from chartflow.node.color_utils import (
2295
+ PORT_GRADIENT_NEUTRAL_LUMINANCE, relative_luminance,
2296
+ )
2297
+ adj = conn._portSideColor(True)
2298
+ self.assertLess(
2299
+ abs(relative_luminance(adj) - PORT_GRADIENT_NEUTRAL_LUMINANCE), 0.01
2300
+ )
2301
+
2302
+
2303
+ def RunAllTests() -> None:
2304
+ """运行所有测试"""
2305
+ # 创建测试套件
2306
+ loader = unittest.TestLoader()
2307
+ suite = unittest.TestSuite()
2308
+
2309
+ # 添加所有测试类
2310
+ suite.addTests(loader.loadTestsFromTestCase(TestEnums))
2311
+ suite.addTests(loader.loadTestsFromTestCase(TestStyles))
2312
+ suite.addTests(loader.loadTestsFromTestCase(TestNodeItem))
2313
+ suite.addTests(loader.loadTestsFromTestCase(TestConnectionItem))
2314
+ suite.addTests(loader.loadTestsFromTestCase(TestArrowItem))
2315
+ suite.addTests(loader.loadTestsFromTestCase(TestSelectionRect))
2316
+ suite.addTests(loader.loadTestsFromTestCase(TestNodeCanvas))
2317
+ suite.addTests(loader.loadTestsFromTestCase(TestQNodeEditor))
2318
+ suite.addTests(loader.loadTestsFromTestCase(TestNodeShapes))
2319
+ suite.addTests(loader.loadTestsFromTestCase(TestArrowDragFunctionality))
2320
+ suite.addTests(loader.loadTestsFromTestCase(TestConnectionTypes))
2321
+ suite.addTests(loader.loadTestsFromTestCase(TestDecorator))
2322
+ suite.addTests(loader.loadTestsFromTestCase(TestPorts))
2323
+ suite.addTests(loader.loadTestsFromTestCase(TestColorUtils))
2324
+ suite.addTests(loader.loadTestsFromTestCase(TestPortGradient))
2325
+
2326
+ # 运行测试
2327
+ runner = unittest.TextTestRunner(verbosity=2)
2328
+ result = runner.run(suite)
2329
+
2330
+ # 返回测试结果
2331
+ return result.wasSuccessful()
2332
+
2333
+
2334
+ if __name__ == "__main__":
2335
+ success = RunAllTests()
2336
+ sys.exit(0 if success else 1)