pytestflow 0.2.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.
- bootstrap_templates/__init__.py +0 -0
- bootstrap_templates/config.yaml +13 -0
- bootstrap_templates/custom_step_types/__init__.py +0 -0
- bootstrap_templates/custom_step_types/custom_step_template.py +7 -0
- bootstrap_templates/process_models/__init__.py +0 -0
- bootstrap_templates/process_models/reporting/README.md +48 -0
- bootstrap_templates/process_models/reporting/__init__.py +0 -0
- bootstrap_templates/process_models/reporting/default_report.html.j2 +122 -0
- bootstrap_templates/process_models/reporting/html_report.py +331 -0
- bootstrap_templates/process_models/sequential_model.py +141 -0
- bootstrap_templates/test_sequences/__init__.py +0 -0
- bootstrap_templates/test_sequences/basic_sequence.py +62 -0
- bootstrap_templates/test_sequences/message_box_and_flow_control.py +169 -0
- bootstrap_templates/test_sequences/motherboard_test_sequence.py +125 -0
- bootstrap_templates/test_sequences/step_types_quickstart.py +168 -0
- pytestflow/README.md +13 -0
- pytestflow/__init__.py +19 -0
- pytestflow/backend/__init__.py +2 -0
- pytestflow/backend/event_bus.py +27 -0
- pytestflow/backend/frontend/assets/full_logo-D1DRTUt8.svg +21 -0
- pytestflow/backend/frontend/assets/index-480TOyh4.js +2 -0
- pytestflow/backend/frontend/assets/index-qEI3VAQU.css +1 -0
- pytestflow/backend/frontend/index.html +14 -0
- pytestflow/backend/frontend/logo.svg +21 -0
- pytestflow/backend/handlers.py +214 -0
- pytestflow/backend/report_manager.py +15 -0
- pytestflow/backend/sequences_info.py +130 -0
- pytestflow/backend/start_backend.py +118 -0
- pytestflow/backend/uuids_handler.py +67 -0
- pytestflow/backend/websocket_gateway.py +91 -0
- pytestflow/cli.py +183 -0
- pytestflow/config/__init__.py +0 -0
- pytestflow/config/config_manager.py +44 -0
- pytestflow/core/README.md +110 -0
- pytestflow/core/__init__.py +15 -0
- pytestflow/core/context.py +41 -0
- pytestflow/core/core.py +112 -0
- pytestflow/core/pytestflow_states.py +88 -0
- pytestflow/core/runtime_control.py +164 -0
- pytestflow/core/seq_file_runner.py +38 -0
- pytestflow/core/sequence.py +404 -0
- pytestflow/core/utils.py +81 -0
- pytestflow/flow_utils/README.md +6 -0
- pytestflow/flow_utils/__init__.py +0 -0
- pytestflow/flow_utils/conditions.py +0 -0
- pytestflow/flow_utils/transitions.py +0 -0
- pytestflow/starter_here.md +43 -0
- pytestflow/steps/README.md +43 -0
- pytestflow/steps/__init__.py +15 -0
- pytestflow/steps/action_step.py +94 -0
- pytestflow/steps/common.py +51 -0
- pytestflow/steps/df_numeric_limits.py +151 -0
- pytestflow/steps/flow_control.py +86 -0
- pytestflow/steps/message_pop_up.py +76 -0
- pytestflow/steps/numeric_limit.py +109 -0
- pytestflow/steps/pass_fail.py +49 -0
- pytestflow/steps/string_check.py +104 -0
- pytestflow/steps/waveform_limit.py +170 -0
- pytestflow-0.2.0.dist-info/METADATA +73 -0
- pytestflow-0.2.0.dist-info/RECORD +63 -0
- pytestflow-0.2.0.dist-info/WHEEL +5 -0
- pytestflow-0.2.0.dist-info/entry_points.txt +2 -0
- pytestflow-0.2.0.dist-info/top_level.txt +2 -0
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Reporting
|
|
2
|
+
|
|
3
|
+
Modules that transform aggregated `PyTestflowState` data into JSON or HTML
|
|
4
|
+
reports. Reporting flows typically run from the process model's `report`
|
|
5
|
+
callback, consuming `ptf_context.locals["main_result"]` to present sequence and
|
|
6
|
+
step outcomes captured by Prefect.
|
|
7
|
+
|
|
8
|
+
## Jinja HTML Reporter (opt-in)
|
|
9
|
+
|
|
10
|
+
`html_report.py` remains the default/legacy path.
|
|
11
|
+
|
|
12
|
+
To opt into Jinja-based rendering, use:
|
|
13
|
+
|
|
14
|
+
- `bootstrap_templates.process_models.reporting.html_jinja_report.generate_html_jinja_report`
|
|
15
|
+
- `bootstrap_templates.process_models.reporting.html_jinja_report.report_callback_jinja`
|
|
16
|
+
|
|
17
|
+
If `jinja2` is missing, only the Jinja reporter path raises a friendly runtime
|
|
18
|
+
error with install guidance. Existing `html_report.py` behavior is unchanged.
|
|
19
|
+
|
|
20
|
+
### Process model callback swap example
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from bootstrap_templates.process_models.sequential_model import SequentialProcessModel
|
|
24
|
+
from bootstrap_templates.process_models.reporting.html_jinja_report import report_callback_jinja
|
|
25
|
+
|
|
26
|
+
process_model = SequentialProcessModel(
|
|
27
|
+
callbacks={
|
|
28
|
+
"main_sequence": main_seq,
|
|
29
|
+
"pre_uut": None,
|
|
30
|
+
"post_uut": None,
|
|
31
|
+
"report": report_callback_jinja, # opt-in
|
|
32
|
+
"database_logging": None,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Direct generation with template/CSS overrides
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from bootstrap_templates.process_models.reporting.html_jinja_report import generate_html_jinja_report
|
|
41
|
+
|
|
42
|
+
report_path = generate_html_jinja_report(
|
|
43
|
+
serial_number="SN-001",
|
|
44
|
+
root_state=main_results,
|
|
45
|
+
template_path="my_template.html.j2", # optional
|
|
46
|
+
css_path="my_report.css", # optional
|
|
47
|
+
)
|
|
48
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{ title }}</title>
|
|
7
|
+
<style>
|
|
8
|
+
{{ css_text }}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<h1>
|
|
13
|
+
{{ title }}
|
|
14
|
+
<span class="badge {{ top_status_class }}">{{ top_status }}</span>
|
|
15
|
+
</h1>
|
|
16
|
+
|
|
17
|
+
<div class="meta">
|
|
18
|
+
<div><b>Generated:</b> {{ generated_at }}</div>
|
|
19
|
+
{% if top_message %}
|
|
20
|
+
<div><b>Message:</b> {{ top_message }}</div>
|
|
21
|
+
{% endif %}
|
|
22
|
+
<div><b>State Type:</b> <code class="inline">{{ top_type }}</code></div>
|
|
23
|
+
<div>
|
|
24
|
+
<b>Steps:</b>
|
|
25
|
+
{{ stats.total }} total -
|
|
26
|
+
{{ stats.passed }} passed /
|
|
27
|
+
{{ stats.failed }} failed /
|
|
28
|
+
{{ stats.done }} done /
|
|
29
|
+
{{ stats.other }} other
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<h2>Summary</h2>
|
|
34
|
+
<table class="summary-table">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr>
|
|
37
|
+
<th width="60">#</th>
|
|
38
|
+
<th>Step</th>
|
|
39
|
+
<th width="120">Status</th>
|
|
40
|
+
<th width="120">State</th>
|
|
41
|
+
<th width="120">Subsequence</th>
|
|
42
|
+
</tr>
|
|
43
|
+
</thead>
|
|
44
|
+
<tbody>
|
|
45
|
+
{% for row in summary_rows %}
|
|
46
|
+
<tr>
|
|
47
|
+
<td>{{ "%03d"|format(row.index) }}</td>
|
|
48
|
+
<td><a href="#{{ row.anchor_id }}">{{ row.label }}</a></td>
|
|
49
|
+
<td><span class="badge {{ row.status_class }}">{{ row.status }}</span></td>
|
|
50
|
+
<td><code class="inline">{{ row.type_label }}</code></td>
|
|
51
|
+
<td><code class="inline">{{ "Yes" if row.is_subsequence else "No" }}</code></td>
|
|
52
|
+
</tr>
|
|
53
|
+
{% endfor %}
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
|
|
57
|
+
<h2>Contents</h2>
|
|
58
|
+
<ul class="toc">
|
|
59
|
+
{% for item in details %}
|
|
60
|
+
<li style="margin-left: {{ item.depth * 12 }}px;">
|
|
61
|
+
<a href="#{{ item.anchor_id }}">{{ item.display_label }}</a>
|
|
62
|
+
</li>
|
|
63
|
+
{% endfor %}
|
|
64
|
+
</ul>
|
|
65
|
+
|
|
66
|
+
<h2>Details</h2>
|
|
67
|
+
{% for item in details %}
|
|
68
|
+
<div id="{{ item.anchor_id }}" class="{{ 'indent' if item.depth > 0 else '' }}">
|
|
69
|
+
<h3>
|
|
70
|
+
{{ item.display_label }}
|
|
71
|
+
<span class="badge {{ item.status_class }}">{{ item.status }}</span>
|
|
72
|
+
</h3>
|
|
73
|
+
|
|
74
|
+
<div class="row">
|
|
75
|
+
<div><b>PyTestFlow State:</b> <code class="inline">{{ item.meta.ptf_state_type }}</code></div>
|
|
76
|
+
<div><b>Prefect State:</b> <code class="inline">{{ item.meta.prefect_state_type }}</code></div>
|
|
77
|
+
{% if item.meta.step_type %}
|
|
78
|
+
<div><b>Step Type:</b> <code class="inline">{{ item.meta.step_type }}</code></div>
|
|
79
|
+
{% endif %}
|
|
80
|
+
{% if item.meta.run_id_value %}
|
|
81
|
+
<div><b>{{ item.meta.run_id_label }}:</b> <code class="inline">{{ item.meta.run_id_value }}</code></div>
|
|
82
|
+
{% endif %}
|
|
83
|
+
{% if item.message %}
|
|
84
|
+
<div><b>Message:</b> {{ item.message }}</div>
|
|
85
|
+
{% endif %}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{% if item.step_details %}
|
|
89
|
+
<table class="summary-table">
|
|
90
|
+
<thead>
|
|
91
|
+
<tr><th colspan="2">Step Details</th></tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
{% for key, value in item.step_details.items() %}
|
|
95
|
+
<tr>
|
|
96
|
+
<td><b>{{ key }}</b></td>
|
|
97
|
+
<td>{{ value }}</td>
|
|
98
|
+
</tr>
|
|
99
|
+
{% endfor %}
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
{% endif %}
|
|
103
|
+
|
|
104
|
+
{% if item.meta.additional_info %}
|
|
105
|
+
<table class="summary-table">
|
|
106
|
+
<thead>
|
|
107
|
+
<tr><th colspan="2">Additional Info</th></tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody>
|
|
110
|
+
{% for key, value in item.meta.additional_info.items() %}
|
|
111
|
+
<tr>
|
|
112
|
+
<td><b>{{ key }}</b></td>
|
|
113
|
+
<td>{{ value }}</td>
|
|
114
|
+
</tr>
|
|
115
|
+
{% endfor %}
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
{% endif %}
|
|
119
|
+
</div>
|
|
120
|
+
{% endfor %}
|
|
121
|
+
</body>
|
|
122
|
+
</html>
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from pytestflow.backend.report_manager import report_manager
|
|
8
|
+
from pytestflow.config.config_manager import ConfigManager
|
|
9
|
+
from pytestflow.core.context import ptf_context
|
|
10
|
+
from pytestflow.steps.action_step import action_step
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_TEMPLATE_PATH = Path(__file__).resolve().parent / "default_report.html.j2"
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
:root { --fg:#222; --bg:#fff; --muted:#666; --border:#ddd; }
|
|
16
|
+
body { font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; color:var(--fg); background:var(--bg); margin:24px; line-height:1.4; }
|
|
17
|
+
h1 { margin:0 0 6px 0; }
|
|
18
|
+
h2 { margin-top: 20px; }
|
|
19
|
+
h3 { margin: 8px 0; }
|
|
20
|
+
.meta { color:var(--muted); margin-bottom:20px; }
|
|
21
|
+
.summary-table { width:100%; border-collapse: collapse; margin: 8px 0 18px 0; }
|
|
22
|
+
.summary-table th, .summary-table td { padding:8px 10px; border-bottom:1px solid var(--border); vertical-align: top; text-align: left; }
|
|
23
|
+
.summary-table th { background:#fafafa; }
|
|
24
|
+
.row { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
|
|
25
|
+
.indent { padding-left: 22px; border-left: 3px solid #f0f0f0; margin: 6px 0 10px 0; }
|
|
26
|
+
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:600; }
|
|
27
|
+
.badge-pass { background:#e8f8ee; color:#117a3a; border:1px solid #bfead0; }
|
|
28
|
+
.badge-fail { background:#fdeceb; color:#a1130f; border:1px solid #f3c2bf; }
|
|
29
|
+
.badge-other { background:#eef2ff; color:#283593; border:1px solid #c5cae9; }
|
|
30
|
+
code.inline { background:#f7f7f9; padding:2px 4px; border-radius:4px; }
|
|
31
|
+
ul.toc { margin: 8px 0 20px 0; }
|
|
32
|
+
ul.toc li { margin: 3px 0; }
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_jinja2():
|
|
37
|
+
try:
|
|
38
|
+
import jinja2
|
|
39
|
+
except Exception as exc: # pragma: no cover - exercised via tests with monkeypatch
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"Jinja reporting requires 'jinja2'. Install it with: pip install jinja2"
|
|
42
|
+
) from exc
|
|
43
|
+
return jinja2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_reports_folder() -> str:
|
|
47
|
+
try:
|
|
48
|
+
config = ConfigManager().get_config()
|
|
49
|
+
return config["test_reports"]
|
|
50
|
+
except Exception:
|
|
51
|
+
return "test_reports"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
REPORTS_FOLDER = _get_reports_folder()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_dir(path: str) -> None:
|
|
58
|
+
os.makedirs(path, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _now_ts() -> str:
|
|
62
|
+
return datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _slug(text: str) -> str:
|
|
66
|
+
text = re.sub(r"\s+", "-", str(text).strip())
|
|
67
|
+
text = re.sub(r"[^a-zA-Z0-9\-_.]+", "", text)
|
|
68
|
+
return text or "item"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_children(state: Any) -> list[tuple[str, Any]]:
|
|
72
|
+
return list(getattr(state, "children", []) or [])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_message(state: Any) -> str:
|
|
76
|
+
return getattr(state, "message", "") or ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_status_name(state: Any) -> str:
|
|
80
|
+
name = getattr(state, "name", None)
|
|
81
|
+
if name:
|
|
82
|
+
return str(name)
|
|
83
|
+
return state.__class__.__name__
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_type_label(state: Any) -> str:
|
|
87
|
+
return str(getattr(state, "type", "")) or ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _guess_step_label(name: str, state: Any) -> str:
|
|
91
|
+
ptf_result = getattr(state, "ptf_result", None)
|
|
92
|
+
if isinstance(ptf_result, dict) and "step_name" in ptf_result:
|
|
93
|
+
return str(ptf_result["step_name"])
|
|
94
|
+
msg = _extract_message(state)
|
|
95
|
+
if name.startswith("<pytestflow.core.sequence.Sequence"):
|
|
96
|
+
match = re.search(r"Sequence\s+(.+?)\s+(passed|failed)", msg, flags=re.I)
|
|
97
|
+
if match:
|
|
98
|
+
return f"{match.group(1)} (subsequence)"
|
|
99
|
+
return "Subsequence"
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _status_class(status: str) -> str:
|
|
104
|
+
status_upper = status.upper()
|
|
105
|
+
if "PAS" in status_upper:
|
|
106
|
+
return "badge-pass"
|
|
107
|
+
if "FAIL" in status_upper or status_upper == "ERROR":
|
|
108
|
+
return "badge-fail"
|
|
109
|
+
return "badge-other"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _collect_step_stats(state: Any) -> dict[str, int]:
|
|
113
|
+
stats = {"passed": 0, "failed": 0, "done": 0, "other": 0, "total": 0}
|
|
114
|
+
for _, child_state in _extract_children(state):
|
|
115
|
+
stats["total"] += 1
|
|
116
|
+
status = _extract_status_name(child_state).lower()
|
|
117
|
+
if "pass" in status:
|
|
118
|
+
stats["passed"] += 1
|
|
119
|
+
elif "fail" in status:
|
|
120
|
+
stats["failed"] += 1
|
|
121
|
+
elif "done" in status:
|
|
122
|
+
stats["done"] += 1
|
|
123
|
+
else:
|
|
124
|
+
stats["other"] += 1
|
|
125
|
+
return stats
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_step_details(state: Any) -> dict[str, Any]:
|
|
129
|
+
ptf_result = getattr(state, "ptf_result", None)
|
|
130
|
+
if not isinstance(ptf_result, dict):
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
excluded = {
|
|
134
|
+
"step_name",
|
|
135
|
+
"flow_run_id",
|
|
136
|
+
"flow_name",
|
|
137
|
+
"task_run_id",
|
|
138
|
+
"task_name",
|
|
139
|
+
"additional_info",
|
|
140
|
+
}
|
|
141
|
+
details: dict[str, Any] = {}
|
|
142
|
+
for key, value in ptf_result.items():
|
|
143
|
+
if key in excluded:
|
|
144
|
+
continue
|
|
145
|
+
details[str(key)] = value
|
|
146
|
+
return details
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_meta(state: Any) -> dict[str, Any]:
|
|
150
|
+
ptf_result = getattr(state, "ptf_result", None)
|
|
151
|
+
task_run_id = getattr(state, "task_run_id", None)
|
|
152
|
+
flow_run_id = getattr(state, "flow_run_id", None)
|
|
153
|
+
has_children = bool(_extract_children(state))
|
|
154
|
+
run_id_label = "Flow Run ID" if has_children else "Task Run ID"
|
|
155
|
+
run_id_value = flow_run_id if has_children else task_run_id
|
|
156
|
+
|
|
157
|
+
meta: dict[str, Any] = {
|
|
158
|
+
"ptf_state_type": state.__class__.__name__,
|
|
159
|
+
"prefect_state_type": _extract_type_label(state),
|
|
160
|
+
"run_id_label": run_id_label,
|
|
161
|
+
"run_id_value": run_id_value,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if isinstance(ptf_result, dict):
|
|
165
|
+
step_type = ptf_result.get("step_type")
|
|
166
|
+
if step_type is not None:
|
|
167
|
+
meta["step_type"] = step_type
|
|
168
|
+
additional_info = ptf_result.get("additional_info")
|
|
169
|
+
if isinstance(additional_info, dict):
|
|
170
|
+
meta["additional_info"] = additional_info
|
|
171
|
+
return meta
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_tree_node(
|
|
175
|
+
state: Any,
|
|
176
|
+
*,
|
|
177
|
+
name: str,
|
|
178
|
+
index: str,
|
|
179
|
+
root_anchor: str,
|
|
180
|
+
depth: int,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
label = _guess_step_label(name, state)
|
|
183
|
+
anchor_id = _slug(f"{root_anchor}-{index}-{label}")
|
|
184
|
+
status = _extract_status_name(state)
|
|
185
|
+
children: list[dict[str, Any]] = []
|
|
186
|
+
for child_idx, (child_name, child_state) in enumerate(_extract_children(state), start=1):
|
|
187
|
+
child_index = f"{index}.{child_idx}" if index else str(child_idx)
|
|
188
|
+
children.append(
|
|
189
|
+
_build_tree_node(
|
|
190
|
+
child_state,
|
|
191
|
+
name=str(child_name),
|
|
192
|
+
index=child_index,
|
|
193
|
+
root_anchor=root_anchor,
|
|
194
|
+
depth=depth + 1,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"label": label,
|
|
200
|
+
"index": index,
|
|
201
|
+
"display_label": f"{index} - {label}" if index else label,
|
|
202
|
+
"anchor_id": anchor_id,
|
|
203
|
+
"status": status,
|
|
204
|
+
"status_class": _status_class(status),
|
|
205
|
+
"type_label": _extract_type_label(state),
|
|
206
|
+
"message": _extract_message(state),
|
|
207
|
+
"is_subsequence": bool(children),
|
|
208
|
+
"meta": _extract_meta(state),
|
|
209
|
+
"step_details": _extract_step_details(state),
|
|
210
|
+
"children": children,
|
|
211
|
+
"depth": depth,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _flatten_tree(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
|
|
216
|
+
out.append(node)
|
|
217
|
+
for child in node["children"]:
|
|
218
|
+
_flatten_tree(child, out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_serial_number_from_context() -> str:
|
|
222
|
+
user_response = ptf_context.locals.get("_pre_uut_user_response")
|
|
223
|
+
if not isinstance(user_response, dict):
|
|
224
|
+
return ""
|
|
225
|
+
return str(user_response.get("text", "") or "")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_report_context(serial_number: str, root_state: Any) -> dict[str, Any]:
|
|
229
|
+
generated_at = _now_ts()
|
|
230
|
+
title = "Test Sequence Report" + (f" - SN: {serial_number}" if serial_number else "")
|
|
231
|
+
root_anchor = _slug(f"root-{title}")
|
|
232
|
+
|
|
233
|
+
root_node = _build_tree_node(
|
|
234
|
+
root_state,
|
|
235
|
+
name=title,
|
|
236
|
+
index="0",
|
|
237
|
+
root_anchor=root_anchor,
|
|
238
|
+
depth=0,
|
|
239
|
+
)
|
|
240
|
+
details: list[dict[str, Any]] = []
|
|
241
|
+
_flatten_tree(root_node, details)
|
|
242
|
+
|
|
243
|
+
summary_rows = []
|
|
244
|
+
for i, child in enumerate(root_node["children"], start=1):
|
|
245
|
+
summary_rows.append(
|
|
246
|
+
{
|
|
247
|
+
"index": i,
|
|
248
|
+
"label": child["label"],
|
|
249
|
+
"anchor_id": child["anchor_id"],
|
|
250
|
+
"status": child["status"],
|
|
251
|
+
"status_class": child["status_class"],
|
|
252
|
+
"type_label": child["type_label"],
|
|
253
|
+
"is_subsequence": child["is_subsequence"],
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"title": title,
|
|
259
|
+
"serial_number": serial_number,
|
|
260
|
+
"generated_at": generated_at,
|
|
261
|
+
"top_status": _extract_status_name(root_state),
|
|
262
|
+
"top_status_class": _status_class(_extract_status_name(root_state)),
|
|
263
|
+
"top_type": _extract_type_label(root_state),
|
|
264
|
+
"top_message": _extract_message(root_state),
|
|
265
|
+
"stats": _collect_step_stats(root_state),
|
|
266
|
+
"summary_rows": summary_rows,
|
|
267
|
+
"tree": root_node,
|
|
268
|
+
"details": details,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_css_text(css_path: str | None) -> str:
|
|
273
|
+
if not css_path:
|
|
274
|
+
return DEFAULT_CSS
|
|
275
|
+
with open(css_path, "r", encoding="utf-8") as file_obj:
|
|
276
|
+
return file_obj.read()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def generate_html_jinja_report(
|
|
280
|
+
serial_number: str,
|
|
281
|
+
root_state: Any,
|
|
282
|
+
out_dir: str | None = None,
|
|
283
|
+
template_path: str | None = None,
|
|
284
|
+
css_path: str | None = None,
|
|
285
|
+
) -> str:
|
|
286
|
+
if out_dir is None:
|
|
287
|
+
out_dir = REPORTS_FOLDER
|
|
288
|
+
_ensure_dir(out_dir)
|
|
289
|
+
|
|
290
|
+
context = build_report_context(serial_number, root_state)
|
|
291
|
+
context["css_text"] = _read_css_text(css_path)
|
|
292
|
+
|
|
293
|
+
jinja2 = _load_jinja2()
|
|
294
|
+
if template_path:
|
|
295
|
+
template_file = Path(template_path).resolve()
|
|
296
|
+
environment = jinja2.Environment(
|
|
297
|
+
loader=jinja2.FileSystemLoader(str(template_file.parent)),
|
|
298
|
+
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
|
299
|
+
)
|
|
300
|
+
template = environment.get_template(template_file.name)
|
|
301
|
+
else:
|
|
302
|
+
environment = jinja2.Environment(
|
|
303
|
+
loader=jinja2.FileSystemLoader(str(DEFAULT_TEMPLATE_PATH.parent)),
|
|
304
|
+
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
|
305
|
+
)
|
|
306
|
+
template = environment.get_template(DEFAULT_TEMPLATE_PATH.name)
|
|
307
|
+
|
|
308
|
+
html_doc = template.render(**context)
|
|
309
|
+
filename = f"{context['generated_at']}_{_slug(context['title'])}.html"
|
|
310
|
+
out_path = os.path.join(out_dir, filename)
|
|
311
|
+
with open(out_path, "w", encoding="utf-8") as file_obj:
|
|
312
|
+
file_obj.write(html_doc)
|
|
313
|
+
return out_path
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@action_step(name="report_callback_jinja")
|
|
317
|
+
def report_callback_jinja():
|
|
318
|
+
main_results = ptf_context.locals.get("main_results") or ptf_context.locals.get("main_result")
|
|
319
|
+
if main_results is None:
|
|
320
|
+
raise ValueError("Main sequence results are missing. Expected 'main_results' in context.")
|
|
321
|
+
|
|
322
|
+
serial_number = _extract_serial_number_from_context()
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
report_path = generate_html_jinja_report(serial_number, main_results)
|
|
326
|
+
print(f"HTML Jinja report generated: {report_path}")
|
|
327
|
+
except Exception as e:
|
|
328
|
+
print(f"Jinja report generation failed: {e}.")
|
|
329
|
+
|
|
330
|
+
report_manager.set_last_report(report_path)
|
|
331
|
+
return report_path
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from pytestflow.core.sequence import Sequence
|
|
2
|
+
from pytestflow.steps.action_step import action_step
|
|
3
|
+
from pytestflow.core.context import ptf_context
|
|
4
|
+
from pytestflow.steps.flow_control import flow_control_step
|
|
5
|
+
from pytestflow.steps.message_pop_up import message_pop_up_step
|
|
6
|
+
from pytestflow.backend.report_manager import report_manager
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
PROCESS_MODELS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
sys.path.insert(0, str(PROCESS_MODELS_DIR))
|
|
12
|
+
|
|
13
|
+
from bootstrap_templates.process_models.reporting.html_report import report_callback_jinja
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------
|
|
17
|
+
# DEFAULT CALLBACKS
|
|
18
|
+
# ---------------------
|
|
19
|
+
|
|
20
|
+
# ✅ Pre-UUT: ask user to enter SN
|
|
21
|
+
@message_pop_up_step(
|
|
22
|
+
show_response_box=True,
|
|
23
|
+
title="User Input",
|
|
24
|
+
msg="Enter Serial Number",
|
|
25
|
+
buttons=["Run", "Cancel"],
|
|
26
|
+
name="pre_uut_callback"
|
|
27
|
+
)
|
|
28
|
+
def pre_uut_callback():
|
|
29
|
+
pass # logic handled by decorator
|
|
30
|
+
|
|
31
|
+
# ✅ Post-UUT: (placeholder)
|
|
32
|
+
@action_step(name="post_uut_callback")
|
|
33
|
+
def post_uut_callback():
|
|
34
|
+
print("📝 Post-UUT executed.")
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ✅ Database logging (placeholder)
|
|
39
|
+
@action_step(name="database_logging_callback")
|
|
40
|
+
def database_logging_callback():
|
|
41
|
+
print("💾 Database logging executed.")
|
|
42
|
+
return "DB logging done"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_CALLBACKS = {
|
|
46
|
+
"pre_uut": pre_uut_callback,
|
|
47
|
+
"main_sequence": None, # mandatory
|
|
48
|
+
"post_uut": post_uut_callback,
|
|
49
|
+
"report": report_callback_jinja,
|
|
50
|
+
"database_logging": database_logging_callback,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SequentialProcessModel(Sequence):
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
name: str = "SequentialProcessModel",
|
|
58
|
+
callbacks: dict | None = None,
|
|
59
|
+
):
|
|
60
|
+
# Validate main sequence presence
|
|
61
|
+
callbacks = callbacks or {}
|
|
62
|
+
# Merge with defaults
|
|
63
|
+
self.callbacks = {**DEFAULT_CALLBACKS, **callbacks}
|
|
64
|
+
self._pre_uut_step_name = None
|
|
65
|
+
|
|
66
|
+
assert self.callbacks["main_sequence"] is not None, "Main Seq callback must be provided."
|
|
67
|
+
|
|
68
|
+
pre_uut_step = self.callbacks.get("pre_uut")
|
|
69
|
+
if pre_uut_step is not None:
|
|
70
|
+
self._pre_uut_step_name = getattr(pre_uut_step, "name", getattr(pre_uut_step, "__name__", "pre_uut"))
|
|
71
|
+
|
|
72
|
+
@flow_control_step(name="pre_uut_flow_gate", next_steps={0: "end", 1: "next"})
|
|
73
|
+
def pre_uut_flow_gate():
|
|
74
|
+
user_response = ptf_context.locals.get("_pre_uut_user_response")
|
|
75
|
+
button = self._extract_button_from_response(user_response)
|
|
76
|
+
return button == "run"
|
|
77
|
+
|
|
78
|
+
steps = []
|
|
79
|
+
if pre_uut_step is not None:
|
|
80
|
+
steps.append(pre_uut_step)
|
|
81
|
+
steps.append(pre_uut_flow_gate)
|
|
82
|
+
|
|
83
|
+
main_sequence = self.callbacks["main_sequence"]
|
|
84
|
+
|
|
85
|
+
@action_step(name="main_sequence_step", store_as="main_results")
|
|
86
|
+
def main_sequence_step():
|
|
87
|
+
if isinstance(main_sequence, Sequence):
|
|
88
|
+
main_state = main_sequence.run(return_state=True)
|
|
89
|
+
elif hasattr(main_sequence, "run"):
|
|
90
|
+
main_state = main_sequence.run(return_state=True)
|
|
91
|
+
else:
|
|
92
|
+
try:
|
|
93
|
+
main_state = main_sequence(return_state=True)
|
|
94
|
+
except TypeError:
|
|
95
|
+
main_state = main_sequence()
|
|
96
|
+
|
|
97
|
+
# Backward compatibility for existing integrations that still read `main_result`.
|
|
98
|
+
ptf_context.locals["main_result"] = main_state
|
|
99
|
+
ptf_context.locals["__last_result__"] = main_state
|
|
100
|
+
return main_state
|
|
101
|
+
|
|
102
|
+
steps.append(main_sequence_step)
|
|
103
|
+
if self.callbacks.get("post_uut") is not None:
|
|
104
|
+
steps.append(self.callbacks["post_uut"])
|
|
105
|
+
if self.callbacks.get("report") is not None:
|
|
106
|
+
steps.append(self.callbacks["report"])
|
|
107
|
+
if self.callbacks.get("database_logging") is not None:
|
|
108
|
+
steps.append(self.callbacks["database_logging"])
|
|
109
|
+
|
|
110
|
+
# Build Sequence
|
|
111
|
+
super().__init__(name=name, steps=steps, apply_throttle=False)
|
|
112
|
+
|
|
113
|
+
def _extract_button_from_response(self, user_response) -> str | None:
|
|
114
|
+
if isinstance(user_response, dict):
|
|
115
|
+
button = user_response.get("button")
|
|
116
|
+
if button is not None:
|
|
117
|
+
return str(button).strip().lower()
|
|
118
|
+
if user_response is None:
|
|
119
|
+
return None
|
|
120
|
+
return str(user_response).strip().lower()
|
|
121
|
+
|
|
122
|
+
def _extract_pre_uut_user_response(self, state):
|
|
123
|
+
ptf_result = getattr(state, "ptf_result", {}) or {}
|
|
124
|
+
if "user_response" in ptf_result:
|
|
125
|
+
return ptf_result.get("user_response")
|
|
126
|
+
|
|
127
|
+
output = ptf_result.get("output")
|
|
128
|
+
if isinstance(output, dict):
|
|
129
|
+
if "user_response" in output:
|
|
130
|
+
return output.get("user_response")
|
|
131
|
+
if "button" in output:
|
|
132
|
+
return output
|
|
133
|
+
return output
|
|
134
|
+
|
|
135
|
+
def _exec_step(self, step_fn):
|
|
136
|
+
name, state = super()._exec_step(step_fn)
|
|
137
|
+
if self._pre_uut_step_name and name == self._pre_uut_step_name:
|
|
138
|
+
ptf_context.locals["_pre_uut_user_response"] = self._extract_pre_uut_user_response(state)
|
|
139
|
+
return name, state
|
|
140
|
+
|
|
141
|
+
PROCESS_MODEL = SequentialProcessModel
|
|
File without changes
|