statable 3.1.0__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.
- codegen/__init__.py +38 -0
- codegen/c_code_generator.py +1752 -0
- codegen/code_merger.py +371 -0
- codegen/code_templates.py +509 -0
- codegen/config.py +167 -0
- codegen/enum_generator.py +377 -0
- codegen/event_queue_generator.py +303 -0
- codegen/interrupt_generator.py +345 -0
- codegen/naming_convention.py +171 -0
- codegen/osal_generator.py +282 -0
- codegen/role_function_generator.py +1244 -0
- codegen/sample_data.py +269 -0
- codegen/state_actions_generator.py +395 -0
- codegen/struct_generator.py +721 -0
- codegen/timer_generator.py +299 -0
- codegen/transition_generator.py +907 -0
- codegen/type_mapper.py +69 -0
- codegen/validate/__init__.py +38 -0
- codegen/validate/change_actions.py +90 -0
- codegen/validate/change_applier.py +416 -0
- codegen/validate/clipboard_manager.py +47 -0
- codegen/validate/data/__init__.py +30 -0
- codegen/validate/data/action_definitions.py +200 -0
- codegen/validate/data/keywords.py +46 -0
- codegen/validate/data/prompt_templates.py +176 -0
- codegen/validate/data/validation_rules.py +205 -0
- codegen/validate/items/__init__.py +30 -0
- codegen/validate/items/base_validator.py +25 -0
- codegen/validate/items/cell_validator.py +275 -0
- codegen/validate/items/custom_type_validator.py +75 -0
- codegen/validate/items/event_validator.py +78 -0
- codegen/validate/items/flag_validator.py +74 -0
- codegen/validate/items/interrupt_validator.py +75 -0
- codegen/validate/items/queue_validator.py +73 -0
- codegen/validate/items/role_function_validator.py +175 -0
- codegen/validate/items/state_validator.py +88 -0
- codegen/validate/items/timer_validator.py +120 -0
- codegen/validate/items/transition_validator.py +113 -0
- codegen/validate/items/variable_validator.py +132 -0
- codegen/validate/logger.py +52 -0
- codegen/validate/models.py +177 -0
- codegen/validate/prompt_generator.py +724 -0
- codegen/validate/response_parser.py +154 -0
- codegen/validate/response_validator.py +275 -0
- codegen/validate/validation_dialog.py +322 -0
- codegen/validate/validator.py +94 -0
- codegen/variable_generator.py +419 -0
- statable/__init__.py +71 -0
- statable/__main__.py +49 -0
- statable/cli.py +251 -0
- statable/global_defs.py +256 -0
- statable/mermaid_gen.py +144 -0
- statable/model.py +426 -0
- statable/parser.py +64 -0
- statable/sample_data.py +435 -0
- statable/shared/__init__.py +24 -0
- statable/shared/condition_library.py +63 -0
- statable/shared/literal_library.py +66 -0
- statable/shared/role_function_library.py +150 -0
- statable/state_machine.py +121 -0
- statable/xml_io.py +1164 -0
- statable-3.1.0.dist-info/METADATA +151 -0
- statable-3.1.0.dist-info/RECORD +117 -0
- statable-3.1.0.dist-info/WHEEL +5 -0
- statable-3.1.0.dist-info/entry_points.txt +2 -0
- statable-3.1.0.dist-info/top_level.txt +3 -0
- statable_gui/__init__.py +23 -0
- statable_gui/action_edit_dialog.py +214 -0
- statable_gui/code_generation_dialog.py +486 -0
- statable_gui/code_generation_settings_dialog.py +522 -0
- statable_gui/common_widgets.py +537 -0
- statable_gui/condition_builder_dialog.py +500 -0
- statable_gui/condition_edit_dialog.py +123 -0
- statable_gui/condition_template_dialog.py +58 -0
- statable_gui/config.py +21 -0
- statable_gui/dialogs.py +439 -0
- statable_gui/event_definition_dialog.py +605 -0
- statable_gui/event_delivery_settings_dialog.py +191 -0
- statable_gui/event_queue_dialog.py +245 -0
- statable_gui/global_defs.py +167 -0
- statable_gui/global_defs_dialog.py +796 -0
- statable_gui/i18n/__init__.py +118 -0
- statable_gui/interrupt_handler_edit_dialog.py +810 -0
- statable_gui/layer_settings_dialog.py +180 -0
- statable_gui/libcntrl/__init__.py +23 -0
- statable_gui/libcntrl/condition_library.py +13 -0
- statable_gui/libcntrl/literal_library.py +13 -0
- statable_gui/libcntrl/literal_management_dialog.py +185 -0
- statable_gui/libcntrl/role_function_edit_dialog.py +145 -0
- statable_gui/libcntrl/role_function_library.py +13 -0
- statable_gui/literal_definition_dialog.py +62 -0
- statable_gui/logger.py +83 -0
- statable_gui/main_window.py +1380 -0
- statable_gui/matrix_table.py +413 -0
- statable_gui/preference_keys.py +18 -0
- statable_gui/preferences.py +74 -0
- statable_gui/role_function_dialog.py +244 -0
- statable_gui/sample_data.py +11 -0
- statable_gui/state_actions_dialog.py +398 -0
- statable_gui/symbol_picker.py +170 -0
- statable_gui/traceball.py +37 -0
- statable_gui/transition_editor_direct/__init__.py +15 -0
- statable_gui/transition_editor_direct/actions_tab.py +856 -0
- statable_gui/transition_editor_direct/code_widget.py +344 -0
- statable_gui/transition_editor_direct/coverage_analyzer.py +163 -0
- statable_gui/transition_editor_direct/dialog.py +335 -0
- statable_gui/transition_editor_direct/draft.py +270 -0
- statable_gui/transition_editor_direct/edit_dialogs.py +160 -0
- statable_gui/transition_editor_direct/overview_tab.py +173 -0
- statable_gui/transition_editor_direct/palette_widget.py +233 -0
- statable_gui/transition_editor_direct/relations_edit_dialog.py +156 -0
- statable_gui/transition_editor_direct/relations_tab.py +230 -0
- statable_gui/transition_editor_direct/system_global_dialog.py +52 -0
- statable_gui/transition_editor_direct/transition_actions_dialog.py +109 -0
- statable_gui/transition_editor_direct/transitions_tab.py +620 -0
- statable_gui/validation_dialog.py +285 -0
- statable_gui/widgets.py +1315 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# statable_gui/transition_editor_direct/code_widget.py
|
|
2
|
+
"""Code display widget (read-only, ActionDraft support / v2.2).
|
|
3
|
+
|
|
4
|
+
Version: 2.2.1 (2026-09-19)
|
|
5
|
+
- Fix: Nested group emitted duplicate transition blocks when parent
|
|
6
|
+
and child both declared the same members. The parent now skips
|
|
7
|
+
labels that any descendant declares.
|
|
8
|
+
|
|
9
|
+
[v2.2 changes]
|
|
10
|
+
- Renders cell_actions (before_transitions / after_transitions).
|
|
11
|
+
Legacy "always" trigger is treated as "before_transitions".
|
|
12
|
+
- Respects early_return per transition (`_handled` guard).
|
|
13
|
+
- §12-5: Recursive group nesting via TransitionRelation.children.
|
|
14
|
+
- Handles entry / exit via State.entry / State.exit lists.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from typing import List
|
|
20
|
+
|
|
21
|
+
from PySide6.QtWidgets import QPlainTextEdit
|
|
22
|
+
from .draft import ActionDraft, ensure_list
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("transition_editor_direct.code")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_VALID_C_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _to_pascal_case(name: str) -> str:
|
|
31
|
+
"""snake_case / camelCase -> PascalCase."""
|
|
32
|
+
if not name:
|
|
33
|
+
return ""
|
|
34
|
+
parts = re.split(r'[_\s]+', name)
|
|
35
|
+
result = ""
|
|
36
|
+
for part in parts:
|
|
37
|
+
if part:
|
|
38
|
+
result += part[0].upper() + part[1:]
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CodeWidget(QPlainTextEdit):
|
|
43
|
+
def __init__(self, draft: ActionDraft, parent=None):
|
|
44
|
+
super().__init__(parent)
|
|
45
|
+
self.draft = draft
|
|
46
|
+
self.setReadOnly(True)
|
|
47
|
+
self.update_code()
|
|
48
|
+
|
|
49
|
+
def update_code(self):
|
|
50
|
+
logger.debug("CodeWidget.update_code called")
|
|
51
|
+
code = self._generate_code()
|
|
52
|
+
self.draft.generated_code = code
|
|
53
|
+
self.setPlainText(code)
|
|
54
|
+
logger.debug(f"Code updated ({len(code)} chars)")
|
|
55
|
+
|
|
56
|
+
# ==================================================================
|
|
57
|
+
# Layer / type resolution
|
|
58
|
+
# ==================================================================
|
|
59
|
+
def _get_layer_name(self) -> str:
|
|
60
|
+
layer = getattr(self.draft, 'layer_name', '') or ''
|
|
61
|
+
if layer:
|
|
62
|
+
return layer
|
|
63
|
+
|
|
64
|
+
ns_count = {}
|
|
65
|
+
for item in self.draft.flow_items:
|
|
66
|
+
names = []
|
|
67
|
+
if item.item_type == "transition":
|
|
68
|
+
names.extend(ensure_list(item.params.get('pre_actions', [])))
|
|
69
|
+
names.extend(ensure_list(item.params.get('else_actions', [])))
|
|
70
|
+
elif item.item_type == "function":
|
|
71
|
+
names.append(item.name)
|
|
72
|
+
for n in names:
|
|
73
|
+
if '.' in n:
|
|
74
|
+
ns = n.split('.', 1)[0]
|
|
75
|
+
if ns and _VALID_C_IDENTIFIER.match(ns):
|
|
76
|
+
ns_count[ns] = ns_count.get(ns, 0) + 1
|
|
77
|
+
|
|
78
|
+
if ns_count:
|
|
79
|
+
return max(ns_count.items(), key=lambda kv: kv[1])[0]
|
|
80
|
+
return ''
|
|
81
|
+
|
|
82
|
+
def _context_type(self) -> str:
|
|
83
|
+
layer = self._get_layer_name()
|
|
84
|
+
return f"TransitionContext_{layer}_t" if layer else "TransitionContext_t"
|
|
85
|
+
|
|
86
|
+
# ==================================================================
|
|
87
|
+
# Reference -> RoleFunc name
|
|
88
|
+
# ==================================================================
|
|
89
|
+
def _role_func_name(self, ref: str) -> str:
|
|
90
|
+
"""Convert reference string to RoleFunc_<Namespace>_<PascalName>."""
|
|
91
|
+
if not ref:
|
|
92
|
+
return ""
|
|
93
|
+
name = ref.strip()
|
|
94
|
+
if not name:
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
if '(' in name:
|
|
98
|
+
name = name.split('(', 1)[0].strip()
|
|
99
|
+
|
|
100
|
+
if name.startswith("RoleFunc_"):
|
|
101
|
+
rest = name[len("RoleFunc_"):]
|
|
102
|
+
rest = rest.replace('.', '_')
|
|
103
|
+
if _VALID_C_IDENTIFIER.match(rest):
|
|
104
|
+
return f"RoleFunc_{rest}"
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
if '.' in name:
|
|
108
|
+
ns, base = name.split('.', 1)
|
|
109
|
+
if not (_VALID_C_IDENTIFIER.match(ns)
|
|
110
|
+
and _VALID_C_IDENTIFIER.match(base)):
|
|
111
|
+
return ""
|
|
112
|
+
pascal = _to_pascal_case(base)
|
|
113
|
+
return f"RoleFunc_{ns}_{pascal}"
|
|
114
|
+
|
|
115
|
+
if not _VALID_C_IDENTIFIER.match(name):
|
|
116
|
+
logger.warning(
|
|
117
|
+
f"_role_func_name: reject invalid identifier: {ref!r}")
|
|
118
|
+
return ""
|
|
119
|
+
pascal = _to_pascal_case(name)
|
|
120
|
+
return f"RoleFunc_{pascal}"
|
|
121
|
+
|
|
122
|
+
def _call_stmt(self, ref: str) -> str:
|
|
123
|
+
fn = self._role_func_name(ref)
|
|
124
|
+
if not fn:
|
|
125
|
+
return f"/* Invalid reference: {ref} */"
|
|
126
|
+
return f"{fn}(transition, ctx);"
|
|
127
|
+
|
|
128
|
+
# ==================================================================
|
|
129
|
+
# Code generation
|
|
130
|
+
# ==================================================================
|
|
131
|
+
def _generate_code(self) -> str:
|
|
132
|
+
logger.debug("=== _generate_code START ===")
|
|
133
|
+
lines = []
|
|
134
|
+
|
|
135
|
+
context_type = self._context_type()
|
|
136
|
+
|
|
137
|
+
# ---- System globals ----
|
|
138
|
+
for g in self.draft.system_globals:
|
|
139
|
+
lines.append(f"{g.type} {g.name} = {g.initial_value};")
|
|
140
|
+
if self.draft.system_globals:
|
|
141
|
+
lines.append("")
|
|
142
|
+
|
|
143
|
+
# ---- Collect role function prototypes ----
|
|
144
|
+
proto_names = set()
|
|
145
|
+
for item in self.draft.flow_items:
|
|
146
|
+
if item.item_type == "transition":
|
|
147
|
+
pre_actions = ensure_list(item.params.get('pre_actions', []))
|
|
148
|
+
else_actions = ensure_list(item.params.get('else_actions', []))
|
|
149
|
+
for p in pre_actions:
|
|
150
|
+
proto_names.add(p)
|
|
151
|
+
for ea in else_actions:
|
|
152
|
+
proto_names.add(ea)
|
|
153
|
+
elif item.item_type == "function":
|
|
154
|
+
proto_names.add(item.name)
|
|
155
|
+
|
|
156
|
+
for a in self.draft.cell_actions:
|
|
157
|
+
if a.role_function:
|
|
158
|
+
proto_names.add(a.role_function)
|
|
159
|
+
|
|
160
|
+
if proto_names:
|
|
161
|
+
for ref in sorted(proto_names):
|
|
162
|
+
func_name = self._role_func_name(ref)
|
|
163
|
+
if not func_name:
|
|
164
|
+
lines.append(
|
|
165
|
+
f"/* Undefined reference (invalid identifier): {ref} */")
|
|
166
|
+
continue
|
|
167
|
+
lines.append(
|
|
168
|
+
f"int {func_name}("
|
|
169
|
+
f"const {context_type} *transition, "
|
|
170
|
+
f"SystemContext_t *ctx);"
|
|
171
|
+
)
|
|
172
|
+
lines.append("")
|
|
173
|
+
|
|
174
|
+
# ---- Body ----
|
|
175
|
+
needs_handled = any(
|
|
176
|
+
item.params.get('early_return', False)
|
|
177
|
+
for item in self.draft.flow_items
|
|
178
|
+
if item.item_type == "transition"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ---- Cell actions (pre): before_transitions (+ legacy always) ----
|
|
182
|
+
pre_actions_for_cell = [
|
|
183
|
+
a for a in self.draft.cell_actions
|
|
184
|
+
if a.trigger in ("before_transitions", "always")
|
|
185
|
+
]
|
|
186
|
+
if pre_actions_for_cell:
|
|
187
|
+
lines.append("/* ===== Cell actions (before_transitions) ===== */")
|
|
188
|
+
for a in pre_actions_for_cell:
|
|
189
|
+
lines.append(" " + self._call_stmt(a.role_function))
|
|
190
|
+
lines.append("")
|
|
191
|
+
|
|
192
|
+
# ---- Transitions (v2.2 §12-5 recursive) ----
|
|
193
|
+
transitions = [it for it in self.draft.flow_items
|
|
194
|
+
if it.item_type == "transition"]
|
|
195
|
+
if transitions:
|
|
196
|
+
if needs_handled:
|
|
197
|
+
lines.append("bool _handled = false;")
|
|
198
|
+
lines.append("")
|
|
199
|
+
|
|
200
|
+
# Build by_label map
|
|
201
|
+
by_label = {}
|
|
202
|
+
for it in transitions:
|
|
203
|
+
lbl = it.params.get('label', '') or ''
|
|
204
|
+
if lbl:
|
|
205
|
+
by_label[lbl] = it
|
|
206
|
+
|
|
207
|
+
# Collect mentioned labels
|
|
208
|
+
mentioned = set()
|
|
209
|
+
|
|
210
|
+
def collect(rel):
|
|
211
|
+
for m in (rel.members or []):
|
|
212
|
+
mentioned.add(m)
|
|
213
|
+
for c in (getattr(rel, 'children', None) or []):
|
|
214
|
+
collect(c)
|
|
215
|
+
|
|
216
|
+
for rel in self.draft.cell_relations:
|
|
217
|
+
collect(rel)
|
|
218
|
+
|
|
219
|
+
# Emit relations in order
|
|
220
|
+
for rel in self.draft.cell_relations:
|
|
221
|
+
self._emit_relation_inline(
|
|
222
|
+
rel, by_label, lines, indent_level=1)
|
|
223
|
+
|
|
224
|
+
# Emit un-mentioned transitions
|
|
225
|
+
for it in transitions:
|
|
226
|
+
lbl = it.params.get('label', '') or ''
|
|
227
|
+
if lbl and lbl in mentioned:
|
|
228
|
+
continue
|
|
229
|
+
self._emit_transition_item(it, lines, indent=1)
|
|
230
|
+
|
|
231
|
+
# ---- Cell actions (post): after_transitions ----
|
|
232
|
+
after_actions = [a for a in self.draft.cell_actions
|
|
233
|
+
if a.trigger == "after_transitions"]
|
|
234
|
+
if after_actions:
|
|
235
|
+
lines.append("/* ===== Cell actions (after_transitions) ===== */")
|
|
236
|
+
for a in after_actions:
|
|
237
|
+
lines.append(" " + self._call_stmt(a.role_function))
|
|
238
|
+
lines.append("")
|
|
239
|
+
|
|
240
|
+
generated = "\n".join(lines).rstrip()
|
|
241
|
+
logger.debug("=== Generated Code ===")
|
|
242
|
+
logger.debug(generated)
|
|
243
|
+
logger.debug("=== _generate_code END ===")
|
|
244
|
+
return generated
|
|
245
|
+
|
|
246
|
+
# ==================================================================
|
|
247
|
+
# §12-5: Recursive relation emission
|
|
248
|
+
# ==================================================================
|
|
249
|
+
def _collect_child_labels_inline(self, rel) -> set:
|
|
250
|
+
"""Recursively collect all labels mentioned in descendants of rel.
|
|
251
|
+
|
|
252
|
+
[v2.2.1 fix]
|
|
253
|
+
Used to avoid emitting the same transition twice when a parent
|
|
254
|
+
and its child both declare the same members.
|
|
255
|
+
"""
|
|
256
|
+
labels = set()
|
|
257
|
+
for child in (getattr(rel, 'children', None) or []):
|
|
258
|
+
labels.update(getattr(child, 'members', None) or [])
|
|
259
|
+
labels.update(self._collect_child_labels_inline(child))
|
|
260
|
+
return labels
|
|
261
|
+
|
|
262
|
+
def _emit_relation_inline(self, rel, by_label, lines, indent_level=1):
|
|
263
|
+
"""Recursively emit one relation into `lines`.
|
|
264
|
+
|
|
265
|
+
[v2.2.1 fix]
|
|
266
|
+
Labels handled by child relations are not emitted by the
|
|
267
|
+
parent, avoiding duplicate transition blocks.
|
|
268
|
+
"""
|
|
269
|
+
pad = " " * indent_level
|
|
270
|
+
shared_cond = (getattr(rel, 'shared_condition', '') or '').strip()
|
|
271
|
+
has_cond = bool(shared_cond)
|
|
272
|
+
|
|
273
|
+
if has_cond:
|
|
274
|
+
lines.append("/* ===== Group (shared_condition) ===== */")
|
|
275
|
+
lines.append(f"{pad}if ({shared_cond}) {{")
|
|
276
|
+
inner_indent = indent_level + 1
|
|
277
|
+
else:
|
|
278
|
+
inner_indent = indent_level
|
|
279
|
+
|
|
280
|
+
# v2.2.1: labels handled by descendants are skipped here
|
|
281
|
+
child_labels = self._collect_child_labels_inline(rel)
|
|
282
|
+
|
|
283
|
+
# Members (skip those delegated to children)
|
|
284
|
+
for label in (getattr(rel, 'members', None) or []):
|
|
285
|
+
if label in child_labels:
|
|
286
|
+
continue
|
|
287
|
+
it = by_label.get(label)
|
|
288
|
+
if it is None:
|
|
289
|
+
continue
|
|
290
|
+
self._emit_transition_item(it, lines, indent=inner_indent)
|
|
291
|
+
|
|
292
|
+
# Children (recursively)
|
|
293
|
+
for child in (getattr(rel, 'children', None) or []):
|
|
294
|
+
self._emit_relation_inline(child, by_label, lines,
|
|
295
|
+
indent_level=inner_indent)
|
|
296
|
+
|
|
297
|
+
if has_cond:
|
|
298
|
+
lines.append(f"{pad}}}")
|
|
299
|
+
|
|
300
|
+
def _emit_transition_item(self, item, lines, indent=1):
|
|
301
|
+
"""Emit one transition item into `lines` at the given indent."""
|
|
302
|
+
params = item.params
|
|
303
|
+
pad = " " * indent
|
|
304
|
+
label = params.get('label', '') or ''
|
|
305
|
+
cond = params.get('condition', '')
|
|
306
|
+
target = params.get('target', '') or self.draft.default_target
|
|
307
|
+
pre_actions = ensure_list(params.get('pre_actions', []))
|
|
308
|
+
else_actions = ensure_list(params.get('else_actions', []))
|
|
309
|
+
has_else = params.get('has_else', True)
|
|
310
|
+
else_target = (params.get('else_target', '')
|
|
311
|
+
or self.draft.default_target)
|
|
312
|
+
early_return = params.get('early_return', False)
|
|
313
|
+
|
|
314
|
+
cond_expr = cond if cond else "1"
|
|
315
|
+
mode = "Commit" if early_return else "Tentative"
|
|
316
|
+
lines.append(f"{pad}/* ===== Transition[{label}] ({mode}) ===== */")
|
|
317
|
+
|
|
318
|
+
if early_return:
|
|
319
|
+
lines.append(f"{pad}if (!_handled && {cond_expr}) {{")
|
|
320
|
+
else:
|
|
321
|
+
lines.append(f"{pad}if ({cond_expr}) {{")
|
|
322
|
+
|
|
323
|
+
for p in pre_actions:
|
|
324
|
+
lines.append(f"{pad} " + self._call_stmt(p))
|
|
325
|
+
if target:
|
|
326
|
+
lines.append(f"{pad} next_state = {target};")
|
|
327
|
+
if early_return:
|
|
328
|
+
lines.append(f"{pad} _handled = true;")
|
|
329
|
+
|
|
330
|
+
if has_else and (else_target or else_actions):
|
|
331
|
+
if early_return:
|
|
332
|
+
lines.append(
|
|
333
|
+
f"{pad}}} else if (!_handled && !({cond_expr})) {{")
|
|
334
|
+
else:
|
|
335
|
+
lines.append(f"{pad}}} else {{")
|
|
336
|
+
for ea in else_actions:
|
|
337
|
+
lines.append(f"{pad} " + self._call_stmt(ea))
|
|
338
|
+
if else_target:
|
|
339
|
+
lines.append(f"{pad} next_state = {else_target};")
|
|
340
|
+
if early_return:
|
|
341
|
+
lines.append(f"{pad} _handled = true;")
|
|
342
|
+
|
|
343
|
+
lines.append(f"{pad}}}")
|
|
344
|
+
lines.append("")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# statable_gui/transition_editor_direct/coverage_analyzer.py
|
|
2
|
+
"""Coverage / reachability analyzer (v2.2).
|
|
3
|
+
|
|
4
|
+
Two levels:
|
|
5
|
+
- analyze_cell : unreachable transitions / duplicate targets / overlaps
|
|
6
|
+
- analyze_state_graph : unreachable / terminal / self-loop states
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, List, Tuple, Set
|
|
12
|
+
|
|
13
|
+
from statable.model import Transition
|
|
14
|
+
from statable.state_machine import StateMachine
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("transition_editor_direct.coverage_analyzer")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DuplicateTargetMap(dict):
|
|
20
|
+
"""Dict subclass where empty compares equal to []."""
|
|
21
|
+
|
|
22
|
+
def __eq__(self, other):
|
|
23
|
+
if isinstance(other, list) and len(other) == 0:
|
|
24
|
+
return len(self) == 0
|
|
25
|
+
return super().__eq__(other)
|
|
26
|
+
|
|
27
|
+
def __ne__(self, other):
|
|
28
|
+
return not self.__eq__(other)
|
|
29
|
+
|
|
30
|
+
__hash__ = None # mutable
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CellReport:
|
|
35
|
+
source: str = ""
|
|
36
|
+
event: str = ""
|
|
37
|
+
transitions_count: int = 0
|
|
38
|
+
unreachable_labels: List[str] = field(default_factory=list)
|
|
39
|
+
duplicate_targets: DuplicateTargetMap = field(default_factory=DuplicateTargetMap)
|
|
40
|
+
overlap_pairs: List[Tuple[str, str]] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class StateGraphReport:
|
|
45
|
+
unreachable_states: List[str] = field(default_factory=list)
|
|
46
|
+
terminal_states: List[str] = field(default_factory=list)
|
|
47
|
+
self_loops: List[Tuple[str, str]] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CoverageAnalyzer:
|
|
51
|
+
"""Cell-internal and state-graph analysis."""
|
|
52
|
+
|
|
53
|
+
# ==================================================================
|
|
54
|
+
# Cell-level analysis
|
|
55
|
+
# ==================================================================
|
|
56
|
+
def analyze_cell(self, sm: StateMachine,
|
|
57
|
+
source: str, event: str) -> CellReport:
|
|
58
|
+
"""Analyze a single (source, event) cell."""
|
|
59
|
+
report = CellReport(source=source, event=event)
|
|
60
|
+
transitions = sm.get_transitions_for_cell(source, event)
|
|
61
|
+
report.transitions_count = len(transitions)
|
|
62
|
+
|
|
63
|
+
if not transitions:
|
|
64
|
+
return report
|
|
65
|
+
|
|
66
|
+
# ---- 1. Unreachable detection ----
|
|
67
|
+
# A transition is unreachable if some earlier transition
|
|
68
|
+
# has early_return=True AND definitely fires:
|
|
69
|
+
# (no condition) OR (has_else=True)
|
|
70
|
+
blocker_found = False
|
|
71
|
+
for t in transitions:
|
|
72
|
+
lbl = getattr(t, 'label', '') or ''
|
|
73
|
+
if blocker_found:
|
|
74
|
+
report.unreachable_labels.append(lbl)
|
|
75
|
+
continue
|
|
76
|
+
er = getattr(t, 'early_return', False)
|
|
77
|
+
cond = (getattr(t, 'condition', '') or '').strip()
|
|
78
|
+
has_else = getattr(t, 'has_else', True)
|
|
79
|
+
if er and (not cond or has_else):
|
|
80
|
+
blocker_found = True
|
|
81
|
+
|
|
82
|
+
# ---- 2. Duplicate targets ----
|
|
83
|
+
target_labels: Dict[str, List[str]] = {}
|
|
84
|
+
for t in transitions:
|
|
85
|
+
target = getattr(t, 'target', '') or ''
|
|
86
|
+
if target:
|
|
87
|
+
lbl = getattr(t, 'label', '') or ''
|
|
88
|
+
target_labels.setdefault(target, []).append(lbl)
|
|
89
|
+
|
|
90
|
+
for tgt, labels in target_labels.items():
|
|
91
|
+
if len(labels) > 1:
|
|
92
|
+
report.duplicate_targets[tgt] = labels
|
|
93
|
+
|
|
94
|
+
# ---- 3. Overlap pairs (same condition string) ----
|
|
95
|
+
seen: Dict[str, str] = {}
|
|
96
|
+
for t in transitions:
|
|
97
|
+
cond = (getattr(t, 'condition', '') or '').strip()
|
|
98
|
+
if not cond:
|
|
99
|
+
continue
|
|
100
|
+
lbl = getattr(t, 'label', '') or ''
|
|
101
|
+
if cond in seen:
|
|
102
|
+
report.overlap_pairs.append((seen[cond], lbl))
|
|
103
|
+
else:
|
|
104
|
+
seen[cond] = lbl
|
|
105
|
+
|
|
106
|
+
return report
|
|
107
|
+
|
|
108
|
+
# ==================================================================
|
|
109
|
+
# State-graph analysis
|
|
110
|
+
# ==================================================================
|
|
111
|
+
def analyze_state_graph(self, sm: StateMachine) -> StateGraphReport:
|
|
112
|
+
"""Analyze the full state graph."""
|
|
113
|
+
report = StateGraphReport()
|
|
114
|
+
|
|
115
|
+
all_states: Set[str] = set(sm.states.keys())
|
|
116
|
+
if not all_states:
|
|
117
|
+
return report
|
|
118
|
+
|
|
119
|
+
# ---- Build outgoing-edge map ----
|
|
120
|
+
outgoing: Dict[str, Set[str]] = {s: set() for s in all_states}
|
|
121
|
+
for t in sm.transitions:
|
|
122
|
+
src = getattr(t, 'source', '') or ''
|
|
123
|
+
tgt = getattr(t, 'target', '') or ''
|
|
124
|
+
if src in outgoing and tgt:
|
|
125
|
+
outgoing[src].add(tgt)
|
|
126
|
+
# else_target does not affect reachability (else is fallback),
|
|
127
|
+
# but treat it as an outgoing edge too for terminal detection.
|
|
128
|
+
etgt = getattr(t, 'else_target', '') or ''
|
|
129
|
+
if src in outgoing and etgt:
|
|
130
|
+
outgoing[src].add(etgt)
|
|
131
|
+
|
|
132
|
+
# ---- Reachability from initial ----
|
|
133
|
+
initial = getattr(sm, 'initial_state', None)
|
|
134
|
+
reachable: Set[str] = set()
|
|
135
|
+
if initial and initial in all_states:
|
|
136
|
+
stack = [initial]
|
|
137
|
+
while stack:
|
|
138
|
+
s = stack.pop()
|
|
139
|
+
if s in reachable:
|
|
140
|
+
continue
|
|
141
|
+
reachable.add(s)
|
|
142
|
+
for nxt in outgoing.get(s, ()): # type: ignore
|
|
143
|
+
if nxt not in reachable:
|
|
144
|
+
stack.append(nxt)
|
|
145
|
+
|
|
146
|
+
report.unreachable_states = sorted(all_states - reachable)
|
|
147
|
+
|
|
148
|
+
# ---- Terminal states (no outgoing edges) ----
|
|
149
|
+
report.terminal_states = sorted(
|
|
150
|
+
s for s in all_states if not outgoing.get(s)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# ---- Self loops ----
|
|
154
|
+
self_loops: List[Tuple[str, str]] = []
|
|
155
|
+
for t in sm.transitions:
|
|
156
|
+
src = getattr(t, 'source', '') or ''
|
|
157
|
+
tgt = getattr(t, 'target', '') or ''
|
|
158
|
+
evt = getattr(t, 'event', '') or ''
|
|
159
|
+
if src and tgt and src == tgt:
|
|
160
|
+
self_loops.append((src, evt))
|
|
161
|
+
report.self_loops = self_loops
|
|
162
|
+
|
|
163
|
+
return report
|