symtest-cli 1.3.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.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,15 @@
1
+ """
2
+ Core components for the CLI Testing Framework
3
+ """
4
+
5
+ from .base_runner import BaseRunner
6
+ from .parallel_runner import ParallelRunner
7
+ from .test_case import TestCase
8
+ from .assertions import Assertions
9
+
10
+ __all__ = [
11
+ 'BaseRunner',
12
+ 'ParallelRunner',
13
+ 'TestCase',
14
+ 'Assertions'
15
+ ]
@@ -0,0 +1,253 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import logging
5
+ from typing import Any, Dict, List, Optional, Pattern
6
+
7
+ from ..file_comparator.factory import ComparatorFactory
8
+
9
+ logger = logging.getLogger("symtest.core.assertions")
10
+
11
+
12
+ def _detect_file_type(file_path: str) -> str:
13
+ """Auto-detect comparator type from file extension."""
14
+ ext = os.path.splitext(file_path)[1].lower()
15
+ _type_map = {
16
+ '.h5': 'h5', '.hdf5': 'h5', '.hdf': 'h5',
17
+ '.json': 'json',
18
+ '.csv': 'csv', '.tsv': 'csv',
19
+ '.xml': 'xml', '.html': 'xml', '.htm': 'xml',
20
+ '.txt': 'text', '.log': 'text', '.out': 'text', '.py': 'text',
21
+ }
22
+ if ext in _type_map:
23
+ return _type_map[ext]
24
+ # For unknown extensions, use binary comparator
25
+ return 'binary'
26
+
27
+
28
+ class ValidationError(AssertionError):
29
+ """Structured assertion failure carrying failure_kind and detailed data.
30
+
31
+ Extends ``AssertionError`` for backward compatibility with existing
32
+ ``except AssertionError`` catch blocks, while adding structured fields
33
+ that reporters and AI consumers can use directly.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ message: str = "",
39
+ *,
40
+ failure_kind: str = "",
41
+ compare_failures: Optional[List[Dict[str, Any]]] = None,
42
+ baseline_updated: Optional[List[str]] = None,
43
+ assertion_results: Optional[List[Dict[str, Any]]] = None,
44
+ ) -> None:
45
+ super().__init__(message)
46
+ self.failure_kind = failure_kind
47
+ self.compare_failures = compare_failures or []
48
+ self.baseline_updated = baseline_updated or []
49
+ self.assertion_results = assertion_results or []
50
+
51
+
52
+ def _build_diff_summary(result: Any) -> Dict[str, Any]:
53
+ """Extract a numerical diff summary from a ``ComparisonResult``.
54
+
55
+ Walks the ``differences`` list and computes:
56
+ - ``total_differences``
57
+ - ``max_rel_error`` / ``max_abs_error`` (for cells that are numeric)
58
+ - ``max_rel_error_at`` / ``max_abs_error_at`` (position string)
59
+ """
60
+ differences = getattr(result, "differences", []) or []
61
+ summary: Dict[str, Any] = {
62
+ "total_differences": len(differences),
63
+ "max_rel_error": None,
64
+ "max_rel_error_at": None,
65
+ "max_abs_error": None,
66
+ "max_abs_error_at": None,
67
+ }
68
+ for diff in differences:
69
+ exp = getattr(diff, "expected", None)
70
+ act = getattr(diff, "actual", None)
71
+ pos = getattr(diff, "position", None)
72
+ try:
73
+ ve = float(exp)
74
+ va = float(act)
75
+ abs_err = abs(va - ve)
76
+ rel_err = abs_err / max(abs(ve), 1e-300) if abs(ve) > 0 else float("inf")
77
+ if summary["max_abs_error"] is None or abs_err > summary["max_abs_error"]:
78
+ summary["max_abs_error"] = abs_err
79
+ summary["max_abs_error_at"] = pos
80
+ if summary["max_rel_error"] is None or rel_err > summary["max_rel_error"]:
81
+ summary["max_rel_error"] = rel_err
82
+ summary["max_rel_error_at"] = pos
83
+ except (ValueError, TypeError):
84
+ pass
85
+ return summary
86
+
87
+
88
+ class Assertions:
89
+ @staticmethod
90
+ def equals(actual: Any, expected: Any, message: str = "") -> bool:
91
+ if actual != expected:
92
+ raise AssertionError(f"{message} Expected: {expected}, but got: {actual}")
93
+ return True
94
+
95
+ @staticmethod
96
+ def contains(container: str, item: str, message: str = "") -> bool:
97
+ """
98
+ Check if the item is contained within the container string.
99
+ This method returns True if the item is found anywhere within the container,
100
+ even if the container contains other information.
101
+ """
102
+ if item not in container:
103
+ raise AssertionError(f"{message} Expected to contain: {item}")
104
+ return True
105
+
106
+ @staticmethod
107
+ def matches(text: str, pattern: str, message: str = "") -> bool:
108
+ if not re.search(pattern, text):
109
+ raise AssertionError(f"{message} Text does not match pattern: {pattern}")
110
+ return True
111
+
112
+ @staticmethod
113
+ def return_code_equals(actual: int, expected: int, message: str = "") -> bool:
114
+ if actual != expected:
115
+ raise AssertionError(f"{message} Expected return code: {expected}, got: {actual}")
116
+ return True
117
+
118
+ @staticmethod
119
+ def compare_files(
120
+ actual_path: str,
121
+ baseline_path: str,
122
+ file_type: Optional[str] = None,
123
+ workspace: Optional[str] = None,
124
+ *,
125
+ update_baseline: bool = False,
126
+ error_analysis: bool = False,
127
+ **comparator_kwargs: Any,
128
+ ) -> Dict[str, Any]:
129
+ """
130
+ Compare two files using the appropriate file comparator.
131
+
132
+ :param actual_path: Path to the file generated by the test command.
133
+ :param baseline_path: Path to the golden / reference file.
134
+ :param file_type: Comparator type ('h5','json','csv','xml','text','binary').
135
+ Auto-detected from file extension if omitted.
136
+ :param workspace: Working directory; both paths are resolved relative to
137
+ this directory when they are not absolute.
138
+ :param update_baseline: If True, on comparison failure copy ``actual`` over
139
+ ``baseline`` and report the update.
140
+ :param error_analysis: If True, enable streaming error statistics over ALL
141
+ numeric cells (CSV/H5 comparators).
142
+ :param comparator_kwargs: Extra keyword arguments forwarded to the comparator
143
+ (e.g. ``rtol=1e-5``, ``atol=1e-8``, ``encoding='utf-8'``).
144
+ :return: A dict with ``identical``, ``error``, ``diff_summary``,
145
+ ``differences``, ``actual``, ``baseline``, ``type``,
146
+ ``comparator_kwargs``, ``baseline_updated``.
147
+ :raises ValidationError: when files differ (unless update_baseline is enabled).
148
+ """
149
+ # Resolve paths relative to workspace
150
+ orig_actual = actual_path
151
+ orig_baseline = baseline_path
152
+ if actual_path and workspace and not os.path.isabs(actual_path):
153
+ actual_path = os.path.join(workspace, actual_path)
154
+ if baseline_path and workspace and not os.path.isabs(baseline_path):
155
+ baseline_path = os.path.join(workspace, baseline_path)
156
+
157
+ # Auto-detect file type from extension
158
+ if not file_type:
159
+ if not actual_path:
160
+ raise ValidationError(
161
+ "File type cannot be auto-detected: 'actual' path is empty. "
162
+ "Specify 'type' explicitly (e.g. 'script' or a custom plugin type).",
163
+ failure_kind="file_compare",
164
+ )
165
+ file_type = _detect_file_type(actual_path)
166
+
167
+ # Extract compare_files() method-level parameters (not constructor kwargs).
168
+ # These control line/column ranges in the comparator's compare_files() call.
169
+ _method_keys = {"start_line", "end_line", "start_column", "end_column"}
170
+ method_params: Dict[str, Any] = {}
171
+ for k in list(comparator_kwargs):
172
+ if k in _method_keys:
173
+ method_params[k] = comparator_kwargs.pop(k)
174
+
175
+ # Convert 1-based user input to 0-based (matches CLI behaviour)
176
+ if "start_line" in method_params:
177
+ method_params["start_line"] = max(0, int(method_params["start_line"]) - 1)
178
+ if "end_line" in method_params and method_params["end_line"] is not None:
179
+ method_params["end_line"] = max(0, int(method_params["end_line"]) - 1)
180
+ if "start_column" in method_params:
181
+ method_params["start_column"] = max(0, int(method_params["start_column"]) - 1)
182
+ if "end_column" in method_params and method_params["end_column"] is not None:
183
+ method_params["end_column"] = max(0, int(method_params["end_column"]) - 1)
184
+
185
+ # Collect tolerances for reporting (before they're consumed by factory)
186
+ reported_kwargs = {
187
+ k: v for k, v in comparator_kwargs.items()
188
+ if k not in ("verbose", "debug", "num_threads", "chunk_size", "encoding")
189
+ }
190
+
191
+ try:
192
+ comparator = ComparatorFactory.create_comparator(
193
+ file_type,
194
+ verbose=True, # always include diff details in the assertion message
195
+ error_analysis=error_analysis,
196
+ **comparator_kwargs,
197
+ )
198
+ result = comparator.compare_files(actual_path, baseline_path, **method_params)
199
+
200
+ # Build structured response
201
+ diff_summary = _build_diff_summary(result)
202
+ response: Dict[str, Any] = {
203
+ "identical": result.identical,
204
+ "error": result.error,
205
+ "actual": orig_actual,
206
+ "baseline": orig_baseline,
207
+ "type": file_type,
208
+ "comparator_kwargs": reported_kwargs,
209
+ "diff_summary": diff_summary,
210
+ "differences": (
211
+ [d.to_dict() for d in result.differences]
212
+ if result.differences else []
213
+ ),
214
+ "error_stats": result.error_stats,
215
+ "command_output": result.command_output,
216
+ "baseline_updated": False,
217
+ }
218
+
219
+ if result.error:
220
+ raise ValidationError(
221
+ f"File comparison error ({orig_actual} vs {orig_baseline}): {result.error}",
222
+ failure_kind="file_compare",
223
+ compare_failures=[response],
224
+ )
225
+
226
+ if not result.identical:
227
+ if update_baseline:
228
+ # Overwrite baseline with actual
229
+ os.makedirs(os.path.dirname(baseline_path), exist_ok=True)
230
+ shutil.copy2(actual_path, baseline_path)
231
+ response["baseline_updated"] = True
232
+ response["identical"] = True # treated as pass
233
+ logger.info(
234
+ " [UPDATE BASELINE] %s → %s",
235
+ orig_actual, orig_baseline,
236
+ )
237
+ return response
238
+ else:
239
+ raise ValidationError(
240
+ f"File comparison failed ({orig_actual} vs {orig_baseline}):\n{result}",
241
+ failure_kind="file_compare",
242
+ compare_failures=[response],
243
+ )
244
+
245
+ return response
246
+
247
+ except ValidationError:
248
+ raise
249
+ except Exception as exc:
250
+ raise ValidationError(
251
+ f"File comparison error ({orig_actual} vs {orig_baseline}): {exc}",
252
+ failure_kind="file_compare",
253
+ )
@@ -0,0 +1,299 @@
1
+ import time
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any, Optional
6
+ from .test_case import TestCase
7
+ from .assertions import Assertions
8
+ from .setup import SetupManager, EnvironmentSetup
9
+ from .execution import execute_single_test_case
10
+ from .history_store import load_history, update_case, check_regression, save_history, reset_cases
11
+ from .last_run_store import update_last_run, get_last_failed_names
12
+ from ..file_comparator.factory import ComparatorFactory
13
+
14
+ logger = logging.getLogger("symtest.core.base_runner")
15
+
16
+ class BaseRunner(ABC):
17
+ def __init__(self, config_file: str, workspace: Optional[str] = None,
18
+ test_case_filter: Optional[List[str]] = None,
19
+ test_case_tag_filter: Optional[List[str]] = None,
20
+ history_dir: Optional[str] = None,
21
+ regression_threshold: float = 1.5,
22
+ update_baseline: bool = False,
23
+ update_history: bool = False,
24
+ error_analysis: bool = False,
25
+ last_failed: bool = False,
26
+ resume: bool = False,
27
+ plugin_dirs: Optional[List[str]] = None):
28
+ if workspace:
29
+ self.workspace = Path(workspace)
30
+ else:
31
+ self.workspace = Path.cwd()
32
+ config_path = Path(config_file)
33
+ if config_path.is_absolute():
34
+ self.config_path = config_path
35
+ else:
36
+ self.config_path = self.workspace / config_path
37
+ self.test_cases: List[TestCase] = []
38
+ self.test_case_filter: Optional[List[str]] = test_case_filter
39
+ self.test_case_tag_filter: Optional[List[str]] = test_case_tag_filter
40
+ if history_dir:
41
+ self.history_dir = str((self.workspace / history_dir).resolve())
42
+ else:
43
+ self.history_dir = None
44
+ self.regression_threshold = regression_threshold
45
+ self.update_baseline = update_baseline
46
+ self.update_history = update_history
47
+ self.error_analysis = error_analysis
48
+ self.last_failed = last_failed
49
+ self.resume = resume
50
+
51
+ # --- workspace plugin directories ---
52
+ resolved_plugin_dirs: List[str] = list(plugin_dirs) if plugin_dirs else []
53
+ default_plugin_dir = self.workspace / "comparators"
54
+ if default_plugin_dir.is_dir() and str(default_plugin_dir.resolve()) not in resolved_plugin_dirs:
55
+ resolved_plugin_dirs.append(str(default_plugin_dir.resolve()))
56
+ ComparatorFactory.set_plugin_dirs(resolved_plugin_dirs)
57
+ self.results: Dict[str, Any] = {
58
+ "total": 0,
59
+ "passed": 0,
60
+ "failed": 0,
61
+ "xfailed": 0,
62
+ "xpassed": 0,
63
+ "updated": 0,
64
+ "details": []
65
+ }
66
+ self.assertions = Assertions()
67
+ self.setup_manager = SetupManager()
68
+
69
+ @abstractmethod
70
+ def load_test_cases(self) -> None:
71
+ """Load test cases from configuration file"""
72
+ pass
73
+
74
+ def load_setup_from_config(self, config: Dict[str, Any]) -> None:
75
+ """从配置文件加载setup配置"""
76
+ setup_config = config.get("setup", {})
77
+
78
+ # 处理环境变量设置
79
+ if "environment_variables" in setup_config:
80
+ env_setup = EnvironmentSetup({"environment_variables": setup_config["environment_variables"]})
81
+ self.setup_manager.add_setup(env_setup)
82
+
83
+ # 这里可以扩展支持其他类型的setup插件
84
+ # 例如:
85
+ # if "custom_setups" in setup_config:
86
+ # for custom_setup_config in setup_config["custom_setups"]:
87
+ # # 动态加载自定义setup插件
88
+ # pass
89
+
90
+ def _apply_test_case_filter(self) -> None:
91
+ """根据 test_case_filter / test_case_tag_filter / --last-failed 过滤测试用例"""
92
+ if self.last_failed and not self.test_case_filter:
93
+ ws = str(self.workspace) if self.workspace else str(Path.cwd())
94
+ failed_names = get_last_failed_names(ws)
95
+ if failed_names:
96
+ logger.info(
97
+ "--last-failed: filtering to %d previously failed case(s): %s",
98
+ len(failed_names), ", ".join(failed_names),
99
+ )
100
+ self.test_case_filter = (
101
+ (self.test_case_filter or []) + failed_names
102
+ )
103
+ else:
104
+ logger.info("--last-failed: no previously failed cases found; running all.")
105
+
106
+ if self.test_case_filter or self.test_case_tag_filter:
107
+ original_count = len(self.test_cases)
108
+ self.test_cases = [
109
+ tc for tc in self.test_cases
110
+ if (not self.test_case_filter or tc.name in self.test_case_filter)
111
+ and (not self.test_case_tag_filter
112
+ or set(tc.tags or []) & set(self.test_case_tag_filter))
113
+ ]
114
+ filtered_out = original_count - len(self.test_cases)
115
+ if filtered_out > 0:
116
+ logger.info("Filtered out %d test case(s). Running %d specified case(s).",
117
+ filtered_out, len(self.test_cases))
118
+ if not self.test_cases:
119
+ logger.warning("No matching test cases found for: names=%s, tags=%s",
120
+ self.test_case_filter, self.test_case_tag_filter)
121
+
122
+ def run_tests(self) -> bool:
123
+ """Run all test cases and return whether all tests passed"""
124
+ try:
125
+ self.load_test_cases()
126
+ self._apply_test_case_filter()
127
+ self.results["total"] = len(self.test_cases)
128
+
129
+ if self.results["total"] == 0:
130
+ logger.warning("No test cases to run.")
131
+ return False
132
+
133
+ # 执行setup任务
134
+ self.setup_manager.setup_all()
135
+
136
+ total_start_time = time.time()
137
+
138
+ logger.info("Starting test execution... Total tests: %d", self.results["total"])
139
+ logger.info("=" * 50)
140
+
141
+ for i, case in enumerate(self.test_cases, 1):
142
+ logger.info("Running test %d/%d: %s", i, self.results["total"], case.name)
143
+ result = self.run_single_test(case)
144
+
145
+ # Apply xfail status mapping before counting
146
+ self._apply_xfail_status(result, case)
147
+
148
+ # ── Echo expected / description / tags ──
149
+ result["expected"] = case.expected if case.expected else None
150
+ result["description"] = case.description or None
151
+ result["tags"] = case.tags or []
152
+ self._fill_hint_command(result, case.name)
153
+
154
+ self.results["details"].append(result)
155
+ duration = result.get("duration", 0)
156
+ status = result["status"]
157
+ if status == "passed":
158
+ self.results["passed"] += 1
159
+ # Check for baseline updates
160
+ if result.get("baseline_updated"):
161
+ self.results["updated"] += 1
162
+ logger.info("✓ Test passed (baseline updated): %s (%.2fs)", case.name, duration)
163
+ elif result.get("flaky"):
164
+ logger.info("✓ Test passed (flaky, %d attempts): %s (%.2fs)",
165
+ result.get("attempts", 1), case.name, duration)
166
+ else:
167
+ logger.info("✓ Test passed: %s (%.2fs)", case.name, duration)
168
+ elif status == "xfailed":
169
+ self.results["xfailed"] += 1
170
+ attempt_info = f" ({result.get('attempts', 1)} attempts)" if result.get("attempts", 1) > 1 else ""
171
+ logger.info("✓ Test xfailed (expected)%s: %s (%.2fs)", attempt_info, case.name, duration)
172
+ if result.get("message"):
173
+ logger.info(" Reason: %s", result.get("xfail_reason", ""))
174
+ logger.info(" Detail: %s", result["message"])
175
+ elif status == "xpassed":
176
+ self.results["xpassed"] += 1
177
+ self.results["failed"] += 1
178
+ logger.error("✗ Test xpassed (unexpected!): %s (%.2fs)", case.name, duration)
179
+ if result.get("message"):
180
+ logger.error(" Error: %s", result["message"])
181
+ logger.warning(" [XPass] Marked as expected_failure but passed — remove the xfail marker.")
182
+ else:
183
+ self.results["failed"] += 1
184
+ if result.get("flaky"):
185
+ logger.error("✗ Test failed (%d attempts): %s (%.2fs)",
186
+ result.get("attempts", 1), case.name, duration)
187
+ else:
188
+ logger.error("✗ Test failed: %s (%.2fs)", case.name, duration)
189
+ if result["message"]:
190
+ logger.error(" Error: %s", result["message"])
191
+
192
+ total_duration = time.time() - total_start_time
193
+ logger.info("=" * 50)
194
+ logger.info(
195
+ "Test execution completed in %.2fs. "
196
+ "Passed: %d, Failed: %d, XFailed: %d, XPassed: %d",
197
+ total_duration,
198
+ self.results["passed"], self.results["failed"],
199
+ self.results["xfailed"], self.results["xpassed"],
200
+ )
201
+
202
+ # Update history & regression detection
203
+ self._update_history()
204
+
205
+ # Save last-run state for --last-failed
206
+ self._save_last_run()
207
+
208
+ # Exit-code rule: failed + xpassed > 0 → non-zero
209
+ return self.results["failed"] == 0 and self.results["xpassed"] == 0
210
+ finally:
211
+ # 确保teardown总是被执行
212
+ self.setup_manager.teardown_all()
213
+
214
+ def _fill_hint_command(self, result: Dict[str, Any], case_name: str) -> None:
215
+ """Fill in the concrete CLI command inside ``next_action_hint``.
216
+
217
+ The execution layer attaches the hint with ``command=None`` because it
218
+ does not know the config file path; the runner does.
219
+ """
220
+ hint = result.get("next_action_hint")
221
+ if not hint or hint.get("command"):
222
+ return
223
+ config = str(self.config_path)
224
+ if hint.get("action") == "update_baseline":
225
+ hint["command"] = (
226
+ f'symtest run "{config}" --update-baseline -t "{case_name}"'
227
+ )
228
+ else:
229
+ hint["command"] = f'symtest run "{config}" -t "{case_name}"'
230
+
231
+ def _apply_xfail_status(self, result: Dict[str, Any], case: "TestCase") -> None:
232
+ """Apply xfail (expected failure) status mapping to a test result.
233
+
234
+ When ``case.expected_failure`` is True:
235
+ - ``passed`` → ``xpassed`` (unexpected pass; counts as a suite failure)
236
+ - any non-passed status → ``xfailed`` (expected failure; not a failure)
237
+
238
+ The ``xfail_reason`` from the case is attached to the result dict so the
239
+ report can display it.
240
+ """
241
+ if not getattr(case, "expected_failure", False):
242
+ return
243
+ xfail_reason = getattr(case, "xfail_reason", "") or ""
244
+ result["xfail_reason"] = xfail_reason
245
+ result["xfail_quiet"] = getattr(case, "xfail_quiet", False)
246
+ if result.get("status") == "passed":
247
+ result["status"] = "xpassed"
248
+ else:
249
+ result["status"] = "xfailed"
250
+
251
+ def _save_last_run(self) -> None:
252
+ """Persist per-case status for ``--last-failed`` support."""
253
+ ws = str(self.workspace) if self.workspace else str(Path.cwd())
254
+ update_last_run(ws, self.results["details"])
255
+
256
+ def _update_history(self) -> None:
257
+ """Update .symtest history with successful run results and check for regressions."""
258
+ if not self.history_dir:
259
+ return
260
+ history = load_history(self.history_dir)
261
+
262
+ if self.update_history:
263
+ run_names = {r["name"] for r in self.results["details"]}
264
+ cleared = reset_cases(history, run_names)
265
+ if cleared:
266
+ logger.info(
267
+ "History reset: cleared %d case(s) before recording this run",
268
+ cleared,
269
+ )
270
+ self.results["history_reset"] = True
271
+ self.results["history_cleared"] = cleared
272
+
273
+ for result in self.results["details"]:
274
+ # Only record successful cases in history; skip failed ones
275
+ if result["status"] != "passed":
276
+ continue
277
+ duration = result.get("duration", 0)
278
+ # Check regression BEFORE updating (compare against old avg)
279
+ warning = check_regression(history, result["name"], duration, self.regression_threshold)
280
+ if warning:
281
+ logger.warning(warning)
282
+ update_case(history, result["name"], duration)
283
+ save_history(self.history_dir, history)
284
+
285
+ def _run_sequence(self, case: TestCase) -> Dict[str, Any]:
286
+ """Run a sequence test case with multiple steps (fail-fast)."""
287
+ from .config_loader import execute_sequence
288
+ return execute_sequence(
289
+ case_name=case.name,
290
+ steps=case.steps,
291
+ workspace=str(self.workspace) if self.workspace else None,
292
+ case_expected=case.expected if case.expected else None,
293
+ resume=self.resume,
294
+ )
295
+
296
+ @abstractmethod
297
+ def run_single_test(self, case: TestCase) -> Dict[str, str]:
298
+ """Run a single test case and return the result"""
299
+ pass