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.
- chartflow/__init__.py +28 -0
- chartflow/_verify_importexport.py +169 -0
- chartflow/edit/__init__.py +119 -0
- chartflow/edit/code_editor.py +1007 -0
- chartflow/edit/code_folder.py +523 -0
- chartflow/edit/completer.py +2652 -0
- chartflow/edit/context_analyzer.py +521 -0
- chartflow/edit/demo.py +469 -0
- chartflow/edit/line_number_area.py +548 -0
- chartflow/edit/minimap.py +581 -0
- chartflow/edit/python_editor.py +509 -0
- chartflow/edit/syntax_highlighter.py +291 -0
- chartflow/edit/test_editor.py +190 -0
- chartflow/flow/__init__.py +31 -0
- chartflow/flow/_demo.py +98 -0
- chartflow/flow/chart.py +1101 -0
- chartflow/flow/editor.py +375 -0
- chartflow/flow/flow.py +6 -0
- chartflow/flow/qchart.py +1250 -0
- chartflow/flow/test/__init__.py +0 -0
- chartflow/flow/test/test_chart.py +537 -0
- chartflow/flow/test_decorator_sync.py +102 -0
- chartflow/fsm/__init__.py +84 -0
- chartflow/fsm/decorators.py +395 -0
- chartflow/fsm/demo.py +381 -0
- chartflow/fsm/demo_pyqt6.py +881 -0
- chartflow/fsm/demo_tree.py +46 -0
- chartflow/fsm/logic.py +447 -0
- chartflow/fsm/meta.py +569 -0
- chartflow/fsm/scheduler.py +427 -0
- chartflow/fsm/stage.py +2079 -0
- chartflow/fsm/stdio.py +66 -0
- chartflow/fsm/test/__init__.py +7 -0
- chartflow/fsm/test/test_decorators.py +210 -0
- chartflow/fsm/test/test_events.py +202 -0
- chartflow/fsm/test/test_integration.py +596 -0
- chartflow/fsm/test/test_logic.py +371 -0
- chartflow/fsm/test/test_meta.py +192 -0
- chartflow/fsm/test/test_runtime_param.py +336 -0
- chartflow/fsm/test/test_scheduler.py +280 -0
- chartflow/fsm/test/test_stage.py +314 -0
- chartflow/fsm/test_behavior_events.py +263 -0
- chartflow/fsm/test_nested_parallel.py +80 -0
- chartflow/fsm/test_stage_spec.py +448 -0
- chartflow/fsm/utils.py +34 -0
- chartflow/node/__init__.py +66 -0
- chartflow/node/_node_base.py +727 -0
- chartflow/node/_node_interaction.py +488 -0
- chartflow/node/_node_interface.py +426 -0
- chartflow/node/_node_markers.py +648 -0
- chartflow/node/_node_ports.py +536 -0
- chartflow/node/_port_interface.py +111 -0
- chartflow/node/arrow_item.py +287 -0
- chartflow/node/color_utils.py +113 -0
- chartflow/node/connection_item.py +939 -0
- chartflow/node/demo.py +258 -0
- chartflow/node/demo_arrow_drag.py +46 -0
- chartflow/node/demo_decorator.py +63 -0
- chartflow/node/demo_ports.py +69 -0
- chartflow/node/enums.py +35 -0
- chartflow/node/example.py +84 -0
- chartflow/node/layout_utils.py +307 -0
- chartflow/node/main_window.py +196 -0
- chartflow/node/menu_bar.py +240 -0
- chartflow/node/node_canvas.py +1730 -0
- chartflow/node/node_item.py +754 -0
- chartflow/node/popmenu.py +472 -0
- chartflow/node/port_item.py +591 -0
- chartflow/node/qnode_editor.py +73 -0
- chartflow/node/selection_rect.py +53 -0
- chartflow/node/style_panel.py +615 -0
- chartflow/node/styles.py +63 -0
- chartflow/node/test_qnode.py +2336 -0
- chartflow/showcase.py +528 -0
- chartflow/theme.py +508 -0
- chartflow-0.1.dist-info/METADATA +23 -0
- chartflow-0.1.dist-info/RECORD +79 -0
- chartflow-0.1.dist-info/WHEEL +5 -0
- chartflow-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Integration tests for qfsm library"""
|
|
2
|
+
|
|
3
|
+
from chartflow.fsm import Stage, sequence, selector
|
|
4
|
+
from chartflow.fsm import Stage, recursive
|
|
5
|
+
from chartflow.fsm import Stage, sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from chartflow.fsm import Stage, Logic, Scheduler
|
|
10
|
+
from chartflow.fsm.decorators import recursive, listen, sequence, selector
|
|
11
|
+
from chartflow.fsm.meta import clear_prototypes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def reset_state():
|
|
16
|
+
"""Reset state before each test"""
|
|
17
|
+
Scheduler._instance = None
|
|
18
|
+
clear_prototypes()
|
|
19
|
+
Stage._Stage__id__ = {}
|
|
20
|
+
Logic._Logic__names__ = {}
|
|
21
|
+
Logic._Logic__types__ = {}
|
|
22
|
+
yield
|
|
23
|
+
Scheduler._instance = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestFullWorkflow:
|
|
27
|
+
"""Test complete state machine workflows"""
|
|
28
|
+
|
|
29
|
+
def test_traffic_light_workflow(self):
|
|
30
|
+
"""Test traffic light state machine"""
|
|
31
|
+
|
|
32
|
+
class TrafficLight(Logic):
|
|
33
|
+
NAME = "TrafficLight"
|
|
34
|
+
LIMIT = 3
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.transitions = []
|
|
38
|
+
super().__init__()
|
|
39
|
+
|
|
40
|
+
def initial(self, inst):
|
|
41
|
+
return "red"
|
|
42
|
+
|
|
43
|
+
def red(self, inst):
|
|
44
|
+
return "green"
|
|
45
|
+
|
|
46
|
+
def green(self, inst):
|
|
47
|
+
return "yellow"
|
|
48
|
+
|
|
49
|
+
@recursive
|
|
50
|
+
def yellow(self, inst):
|
|
51
|
+
return "red"
|
|
52
|
+
|
|
53
|
+
def onChanged(self, src, dst, inst):
|
|
54
|
+
self.transitions.append((src, dst))
|
|
55
|
+
|
|
56
|
+
light = TrafficLight()
|
|
57
|
+
scheduler = Scheduler()
|
|
58
|
+
|
|
59
|
+
# First tick - initial to red
|
|
60
|
+
scheduler.handle()
|
|
61
|
+
assert light._current == "red"
|
|
62
|
+
|
|
63
|
+
# Second tick - red to green
|
|
64
|
+
scheduler.handle()
|
|
65
|
+
assert light._current == "green"
|
|
66
|
+
|
|
67
|
+
# Third tick - green to yellow, then yellow -> red (recursive in same tick)
|
|
68
|
+
scheduler.handle()
|
|
69
|
+
assert light._current == "red" # Yellow is recursive, immediately goes to red
|
|
70
|
+
|
|
71
|
+
def test_counter_workflow(self):
|
|
72
|
+
"""Test counter with recursive state"""
|
|
73
|
+
|
|
74
|
+
class Counter(Logic):
|
|
75
|
+
NAME = "Counter"
|
|
76
|
+
LIMIT = 10
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self.count = 0
|
|
80
|
+
super().__init__()
|
|
81
|
+
|
|
82
|
+
@recursive
|
|
83
|
+
def counting(self, inst):
|
|
84
|
+
self.count += 1
|
|
85
|
+
if self.count >= 5:
|
|
86
|
+
return "done"
|
|
87
|
+
return "counting"
|
|
88
|
+
|
|
89
|
+
def done(self, inst):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
counter = Counter()
|
|
93
|
+
scheduler = Scheduler()
|
|
94
|
+
|
|
95
|
+
counter._current = "counting"
|
|
96
|
+
scheduler.handle()
|
|
97
|
+
|
|
98
|
+
assert counter.count == 5
|
|
99
|
+
assert counter._current == "done"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestMultipleInstances:
|
|
103
|
+
"""Test multiple interacting logic instances"""
|
|
104
|
+
|
|
105
|
+
def test_priority_ordering(self):
|
|
106
|
+
"""Test that higher priority instances are processed first"""
|
|
107
|
+
|
|
108
|
+
class LowPriority(Logic):
|
|
109
|
+
NAME = "LowPriority"
|
|
110
|
+
PRIORITY = 0
|
|
111
|
+
|
|
112
|
+
def __init__(self):
|
|
113
|
+
self.order = None
|
|
114
|
+
super().__init__()
|
|
115
|
+
|
|
116
|
+
def state(self, inst):
|
|
117
|
+
if self.order is None:
|
|
118
|
+
self.order = Logic._Logic__execution_order
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
class HighPriority(Logic):
|
|
122
|
+
NAME = "HighPriority"
|
|
123
|
+
PRIORITY = 10
|
|
124
|
+
|
|
125
|
+
def __init__(self):
|
|
126
|
+
self.order = None
|
|
127
|
+
super().__init__()
|
|
128
|
+
|
|
129
|
+
def state(self, inst):
|
|
130
|
+
if self.order is None:
|
|
131
|
+
self.order = Logic._Logic__execution_order
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Track execution order
|
|
135
|
+
Logic._Logic__execution_order = 0
|
|
136
|
+
|
|
137
|
+
low = LowPriority()
|
|
138
|
+
high = HighPriority()
|
|
139
|
+
|
|
140
|
+
scheduler = Scheduler()
|
|
141
|
+
scheduler.handle()
|
|
142
|
+
|
|
143
|
+
# Both should have been processed
|
|
144
|
+
assert low._isEntered
|
|
145
|
+
assert high._isEntered
|
|
146
|
+
|
|
147
|
+
def test_instance_referencing(self):
|
|
148
|
+
"""Test that instances can reference each other"""
|
|
149
|
+
|
|
150
|
+
class Worker(Logic):
|
|
151
|
+
NAME = "Worker"
|
|
152
|
+
|
|
153
|
+
def __init__(self):
|
|
154
|
+
self.partner = None
|
|
155
|
+
super().__init__()
|
|
156
|
+
|
|
157
|
+
def state(self, inst):
|
|
158
|
+
if self.partner is None:
|
|
159
|
+
# Find another worker
|
|
160
|
+
others = self.ref("Worker")
|
|
161
|
+
for other in others:
|
|
162
|
+
if other is not self:
|
|
163
|
+
self.partner = other
|
|
164
|
+
break
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
worker1 = Worker()
|
|
168
|
+
worker2 = Worker()
|
|
169
|
+
|
|
170
|
+
scheduler = Scheduler()
|
|
171
|
+
scheduler.handle()
|
|
172
|
+
|
|
173
|
+
assert worker1.partner is worker2 or worker1.partner is None
|
|
174
|
+
assert worker2.partner is worker1 or worker2.partner is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestEventDriven:
|
|
178
|
+
"""Test event-driven state changes"""
|
|
179
|
+
|
|
180
|
+
def test_event_listener(self):
|
|
181
|
+
"""Test that @listen decorator correctly marks methods as listeners"""
|
|
182
|
+
|
|
183
|
+
class EventDriven(Logic):
|
|
184
|
+
NAME = "EventDriven"
|
|
185
|
+
|
|
186
|
+
def __init__(self):
|
|
187
|
+
super().__init__()
|
|
188
|
+
|
|
189
|
+
def waiting(self, inst):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
@listen("trigger")
|
|
193
|
+
def on_trigger(self, data):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
logic = EventDriven()
|
|
197
|
+
|
|
198
|
+
# Verify __listens__ is correctly populated
|
|
199
|
+
assert "trigger" in logic.__listens__
|
|
200
|
+
assert "on_trigger" in [h[0] for h in logic.__listens__["trigger"]]
|
|
201
|
+
# Verify @listen methods are not in __states__
|
|
202
|
+
assert "on_trigger" not in logic.__states__
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TestBehaviorTree:
|
|
206
|
+
"""Test behavior tree composites in integration"""
|
|
207
|
+
|
|
208
|
+
def test_sequence_all_succeed(self):
|
|
209
|
+
"""Test sequence when all children succeed"""
|
|
210
|
+
|
|
211
|
+
class SequenceTest(Logic):
|
|
212
|
+
NAME = "SequenceTest"
|
|
213
|
+
|
|
214
|
+
def __init__(self):
|
|
215
|
+
self.executed = []
|
|
216
|
+
super().__init__()
|
|
217
|
+
|
|
218
|
+
@sequence("step2", "step3")
|
|
219
|
+
def step1(self, it):
|
|
220
|
+
self.executed.append(1)
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
def step2(self, it):
|
|
224
|
+
self.executed.append(2)
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
def step3(self, it):
|
|
228
|
+
self.executed.append(3)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
logic = SequenceTest()
|
|
232
|
+
scheduler = Scheduler()
|
|
233
|
+
|
|
234
|
+
# Tick-based execution: each handle() executes one step
|
|
235
|
+
scheduler.handle() # Executes step1 (enters sequence) and step2
|
|
236
|
+
scheduler.handle() # Executes step3
|
|
237
|
+
|
|
238
|
+
assert 2 in logic.executed
|
|
239
|
+
assert 3 in logic.executed
|
|
240
|
+
|
|
241
|
+
def test_sequence_fails_on_false(self):
|
|
242
|
+
"""Test sequence stops when a child returns False"""
|
|
243
|
+
|
|
244
|
+
class SequenceFailTest(Logic):
|
|
245
|
+
NAME = "SequenceFailTest"
|
|
246
|
+
|
|
247
|
+
def __init__(self):
|
|
248
|
+
self.executed = []
|
|
249
|
+
super().__init__()
|
|
250
|
+
|
|
251
|
+
@sequence("step2", "step3")
|
|
252
|
+
def step1(self, it):
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
def step2(self, it):
|
|
256
|
+
self.executed.append(2)
|
|
257
|
+
return False # Fail - should stop sequence
|
|
258
|
+
|
|
259
|
+
def step3(self, it):
|
|
260
|
+
self.executed.append(3)
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
logic = SequenceFailTest()
|
|
264
|
+
scheduler = Scheduler()
|
|
265
|
+
|
|
266
|
+
scheduler.handle()
|
|
267
|
+
|
|
268
|
+
assert 2 in logic.executed
|
|
269
|
+
assert 3 not in logic.executed # step3 should not execute
|
|
270
|
+
|
|
271
|
+
def test_selector_integration(self):
|
|
272
|
+
"""Test selector composite in full workflow"""
|
|
273
|
+
|
|
274
|
+
class SelectorTest(Logic):
|
|
275
|
+
NAME = "SelectorTest"
|
|
276
|
+
|
|
277
|
+
def __init__(self):
|
|
278
|
+
self.tried = []
|
|
279
|
+
super().__init__()
|
|
280
|
+
|
|
281
|
+
@selector("option1", "option2")
|
|
282
|
+
def selector_state(self, it):
|
|
283
|
+
self.tried.append("selector")
|
|
284
|
+
return False # Fail, try options
|
|
285
|
+
|
|
286
|
+
def option1(self, it):
|
|
287
|
+
self.tried.append("option1")
|
|
288
|
+
return True # Success
|
|
289
|
+
|
|
290
|
+
def option2(self, it):
|
|
291
|
+
self.tried.append("option2")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
logic = SelectorTest()
|
|
295
|
+
scheduler = Scheduler()
|
|
296
|
+
|
|
297
|
+
logic._current = "selector_state"
|
|
298
|
+
|
|
299
|
+
scheduler.handle()
|
|
300
|
+
|
|
301
|
+
# Selector executes children directly, not the parent state's method
|
|
302
|
+
assert "option1" in logic.tried
|
|
303
|
+
|
|
304
|
+
def test_behavior_tree_with_listen_decorator(self):
|
|
305
|
+
"""Test that @listen methods are not treated as states"""
|
|
306
|
+
|
|
307
|
+
class BehaviorWithListen(Logic):
|
|
308
|
+
NAME = "BehaviorWithListen"
|
|
309
|
+
|
|
310
|
+
def __init__(self):
|
|
311
|
+
self.executed = []
|
|
312
|
+
self.event_received = False
|
|
313
|
+
super().__init__()
|
|
314
|
+
|
|
315
|
+
@sequence("step2", "step3")
|
|
316
|
+
def step1(self, it):
|
|
317
|
+
self.executed.append(1)
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
def step2(self, it):
|
|
321
|
+
self.executed.append(2)
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
def step3(self, it):
|
|
325
|
+
self.executed.append(3)
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
@listen("test_event")
|
|
329
|
+
def on_test_event(self, data):
|
|
330
|
+
self.event_received = True
|
|
331
|
+
|
|
332
|
+
logic = BehaviorWithListen()
|
|
333
|
+
scheduler = Scheduler()
|
|
334
|
+
|
|
335
|
+
# Verify __states__ doesn't include on_test_event
|
|
336
|
+
assert "on_test_event" not in logic.__states__
|
|
337
|
+
assert "step1" in logic.__states__
|
|
338
|
+
assert "step2" in logic.__states__
|
|
339
|
+
assert "step3" in logic.__states__
|
|
340
|
+
|
|
341
|
+
# Verify __listens__ is correctly populated
|
|
342
|
+
assert "test_event" in logic.__listens__
|
|
343
|
+
|
|
344
|
+
# Tick-based execution: each handle() executes one step
|
|
345
|
+
scheduler.handle() # Executes step1 (enters sequence) and step2
|
|
346
|
+
scheduler.handle() # Executes step3
|
|
347
|
+
|
|
348
|
+
# Verify sequence executed
|
|
349
|
+
assert 2 in logic.executed
|
|
350
|
+
assert 3 in logic.executed
|
|
351
|
+
|
|
352
|
+
def test_sequence_running_resume(self):
|
|
353
|
+
"""Test that RUNNING (None) pauses and resumes from same child, skipping parent."""
|
|
354
|
+
|
|
355
|
+
class RunningTest(Logic):
|
|
356
|
+
NAME = "RunningTest"
|
|
357
|
+
|
|
358
|
+
def __init__(self):
|
|
359
|
+
self.first_calls = 0
|
|
360
|
+
self.second_calls = 0
|
|
361
|
+
super().__init__()
|
|
362
|
+
|
|
363
|
+
@sequence("child1", "child2")
|
|
364
|
+
def parent(self, it):
|
|
365
|
+
self.first_calls += 1
|
|
366
|
+
it.now = self.now # Set marker on first call
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
def child1(self, it):
|
|
370
|
+
self.second_calls += 1
|
|
371
|
+
# On first tick, return RUNNING (None)
|
|
372
|
+
# On subsequent ticks, if it.now != self.now, succeed
|
|
373
|
+
if not hasattr(it, 'now'):
|
|
374
|
+
it.now = 0
|
|
375
|
+
if it.now == self.now:
|
|
376
|
+
return None # RUNNING
|
|
377
|
+
it.now = self.now
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
def child2(self, it):
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
logic = RunningTest()
|
|
384
|
+
scheduler = Scheduler()
|
|
385
|
+
|
|
386
|
+
# Tick 1: parent executes, child1 returns RUNNING
|
|
387
|
+
scheduler.handle()
|
|
388
|
+
assert logic.first_calls == 1 # Parent executed once
|
|
389
|
+
assert logic.second_calls == 1 # Child1 executed once
|
|
390
|
+
|
|
391
|
+
# Tick 2: should resume child1 directly, skip parent
|
|
392
|
+
scheduler.handle()
|
|
393
|
+
assert logic.first_calls == 1 # Parent still only executed once
|
|
394
|
+
assert logic.second_calls == 2 # Child1 executed again (resumed)
|
|
395
|
+
# Sequence pauses after child1 succeeds (tick-based execution)
|
|
396
|
+
|
|
397
|
+
# Tick 3: continue sequence, execute child2
|
|
398
|
+
scheduler.handle()
|
|
399
|
+
assert logic.first_calls == 1 # Parent still only executed once
|
|
400
|
+
assert logic.second_calls == 2 # Child1 not executed again
|
|
401
|
+
# Sequence completes after child2
|
|
402
|
+
|
|
403
|
+
# Tick 4: new sequence starts from beginning
|
|
404
|
+
scheduler.handle()
|
|
405
|
+
assert logic.first_calls == 2 # Parent executed again
|
|
406
|
+
assert logic.second_calls == 3 # Child1 executed again
|
|
407
|
+
|
|
408
|
+
def test_nested_behavior_tree_indentation(self, capsys):
|
|
409
|
+
"""Test that nested behavior trees have correct indentation in output."""
|
|
410
|
+
|
|
411
|
+
class TestLogic(Stage):
|
|
412
|
+
NAME = "TestIndent"
|
|
413
|
+
|
|
414
|
+
@sequence("child_seq", "child_sel")
|
|
415
|
+
def root(self, it):
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
@sequence("leaf_a", "leaf_b")
|
|
419
|
+
def child_seq(self, it):
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
@selector("leaf_c", "leaf_d")
|
|
423
|
+
def child_sel(self, it):
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
def leaf_a(self, it):
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
def leaf_b(self, it):
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
def leaf_c(self, it):
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
def leaf_d(self, it):
|
|
436
|
+
return True
|
|
437
|
+
|
|
438
|
+
logic = TestLogic()
|
|
439
|
+
logic.handleStage()
|
|
440
|
+
|
|
441
|
+
captured = capsys.readouterr()
|
|
442
|
+
output = captured.out
|
|
443
|
+
|
|
444
|
+
# 老版本输出格式:使用 std.info,行为树内部转换使用 [...] 作为 tag
|
|
445
|
+
# 例如:[...] root -> child_seq
|
|
446
|
+
lines = output.split('\n')
|
|
447
|
+
|
|
448
|
+
# 检查是否有行为树内部转换的输出(使用 [...] 作为 tag)
|
|
449
|
+
bt_transitions = []
|
|
450
|
+
for line in lines:
|
|
451
|
+
if '[...]' in line and '->' in line:
|
|
452
|
+
bt_transitions.append(line)
|
|
453
|
+
|
|
454
|
+
# 应该至少有行为树内部的转换输出
|
|
455
|
+
assert len(bt_transitions) > 0, f"Expected behavior tree transitions with [...] tag, got: {output}"
|
|
456
|
+
|
|
457
|
+
# 检查是否包含从 root 到 child_seq 的转换
|
|
458
|
+
root_to_child = any('root -> child_seq' in line for line in bt_transitions)
|
|
459
|
+
assert root_to_child, f"Expected transition root -> child_seq, got: {bt_transitions}"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestLifecycleManagement:
|
|
463
|
+
"""Test instance lifecycle management"""
|
|
464
|
+
|
|
465
|
+
def test_instance_cleanup(self):
|
|
466
|
+
"""Test that dead instances are cleaned up"""
|
|
467
|
+
|
|
468
|
+
class ShortLived(Logic):
|
|
469
|
+
NAME = "ShortLived"
|
|
470
|
+
|
|
471
|
+
def __init__(self):
|
|
472
|
+
self.ticks = 0
|
|
473
|
+
super().__init__()
|
|
474
|
+
|
|
475
|
+
def state(self, inst):
|
|
476
|
+
self.ticks += 1
|
|
477
|
+
if self.ticks >= 2:
|
|
478
|
+
self.kill()
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
logic = ShortLived()
|
|
482
|
+
scheduler = Scheduler()
|
|
483
|
+
|
|
484
|
+
assert logic in scheduler
|
|
485
|
+
|
|
486
|
+
scheduler.handle()
|
|
487
|
+
assert logic.alive
|
|
488
|
+
|
|
489
|
+
scheduler.handle()
|
|
490
|
+
assert not logic.alive
|
|
491
|
+
|
|
492
|
+
scheduler.handle()
|
|
493
|
+
assert logic not in scheduler
|
|
494
|
+
|
|
495
|
+
def test_enable_disable(self):
|
|
496
|
+
"""Test that disabled instances are skipped"""
|
|
497
|
+
|
|
498
|
+
class Toggleable(Logic):
|
|
499
|
+
NAME = "Toggleable"
|
|
500
|
+
|
|
501
|
+
def __init__(self):
|
|
502
|
+
self.ticks = 0
|
|
503
|
+
super().__init__()
|
|
504
|
+
|
|
505
|
+
def state(self, inst):
|
|
506
|
+
self.ticks += 1
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
logic = Toggleable()
|
|
510
|
+
scheduler = Scheduler()
|
|
511
|
+
|
|
512
|
+
scheduler.handle()
|
|
513
|
+
assert logic.ticks == 1
|
|
514
|
+
|
|
515
|
+
logic.disable()
|
|
516
|
+
scheduler.handle()
|
|
517
|
+
assert logic.ticks == 1 # Should not increment
|
|
518
|
+
|
|
519
|
+
logic.enable()
|
|
520
|
+
scheduler.handle()
|
|
521
|
+
assert logic.ticks == 2
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class TestRecursiveExecution:
|
|
525
|
+
"""Test recursive state execution with None return"""
|
|
526
|
+
|
|
527
|
+
def test_recursive_with_none_return(self):
|
|
528
|
+
"""Test that recursive state with None return re-executes"""
|
|
529
|
+
|
|
530
|
+
class TestLogic(Stage):
|
|
531
|
+
NAME = "TestRecursive"
|
|
532
|
+
|
|
533
|
+
def __init__(self):
|
|
534
|
+
self.count = 0
|
|
535
|
+
super().__init__()
|
|
536
|
+
|
|
537
|
+
@recursive
|
|
538
|
+
def counter(self, it):
|
|
539
|
+
self.count += 1
|
|
540
|
+
if self.count >= 3:
|
|
541
|
+
return "done"
|
|
542
|
+
return None # Should re-execute because of @recursive
|
|
543
|
+
|
|
544
|
+
def done(self, it):
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
logic = TestLogic()
|
|
548
|
+
logic._current = "counter"
|
|
549
|
+
|
|
550
|
+
# Single tick should execute counter 3 times
|
|
551
|
+
logic.handleStage(log=False)
|
|
552
|
+
|
|
553
|
+
assert logic.count == 3, f"Expected count=3, got {logic.count}"
|
|
554
|
+
assert logic.current == "done"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class TestBehaviorTreeBREAK:
|
|
558
|
+
"""Test behavior tree BREAK to external state"""
|
|
559
|
+
|
|
560
|
+
def test_break_to_external_prints_transition(self, capsys):
|
|
561
|
+
"""Test that BREAK to external state prints the transition"""
|
|
562
|
+
|
|
563
|
+
class TestLogic(Stage):
|
|
564
|
+
NAME = "TestBREAK"
|
|
565
|
+
|
|
566
|
+
@sequence("child1", "child2")
|
|
567
|
+
def root(self, it):
|
|
568
|
+
return True
|
|
569
|
+
|
|
570
|
+
def child1(self, it):
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
def child2(self, it):
|
|
574
|
+
return "external" # BREAK to external state
|
|
575
|
+
|
|
576
|
+
def external(self, it):
|
|
577
|
+
return "exit"
|
|
578
|
+
|
|
579
|
+
def exit(self, it):
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
logic = TestLogic()
|
|
583
|
+
# Execute multiple ticks to complete the behavior tree
|
|
584
|
+
for _ in range(5):
|
|
585
|
+
logic.handleStage()
|
|
586
|
+
if logic.current not in ["root", "child1", "child2"]:
|
|
587
|
+
break
|
|
588
|
+
|
|
589
|
+
captured = capsys.readouterr()
|
|
590
|
+
output = captured.out
|
|
591
|
+
|
|
592
|
+
# Should print the BREAK transition
|
|
593
|
+
assert "child2 -> external" in output, f"Expected 'child2 -> external' in output, got: {output}"
|
|
594
|
+
|
|
595
|
+
# Should use [LogicName.fbm] tag for external transition
|
|
596
|
+
assert f"[{logic.name}.fbm]" in output, f"Expected '[{logic.name}.fbm]' tag in output"
|