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.
- symtest/__init__.py +45 -0
- symtest/cli.py +549 -0
- symtest/commands/__init__.py +9 -0
- symtest/commands/compare.py +221 -0
- symtest/config/__init__.py +7 -0
- symtest/config/config_io.py +346 -0
- symtest/config/config_schema.py +330 -0
- symtest/config/import_expander.py +149 -0
- symtest/config/inheritance_expander.py +197 -0
- symtest/core/__init__.py +15 -0
- symtest/core/assertions.py +253 -0
- symtest/core/base_runner.py +299 -0
- symtest/core/config_loader.py +536 -0
- symtest/core/execution.py +498 -0
- symtest/core/history_store.py +96 -0
- symtest/core/last_run_store.py +109 -0
- symtest/core/parallel_runner.py +251 -0
- symtest/core/process_worker.py +93 -0
- symtest/core/sequence_state.py +143 -0
- symtest/core/setup.py +137 -0
- symtest/core/test_case.py +76 -0
- symtest/core/types.py +92 -0
- symtest/file_comparator/__init__.py +10 -0
- symtest/file_comparator/base_comparator.py +109 -0
- symtest/file_comparator/binary_comparator.py +399 -0
- symtest/file_comparator/csv_comparator.py +241 -0
- symtest/file_comparator/factory.py +191 -0
- symtest/file_comparator/h5_comparator.py +777 -0
- symtest/file_comparator/json_comparator.py +323 -0
- symtest/file_comparator/result.py +213 -0
- symtest/file_comparator/script_comparator.py +182 -0
- symtest/file_comparator/text_comparator.py +182 -0
- symtest/file_comparator/xml_comparator.py +150 -0
- symtest/logging_config.py +66 -0
- symtest/runners/__init__.py +15 -0
- symtest/runners/config_runner.py +96 -0
- symtest/runners/json_runner.py +21 -0
- symtest/runners/parallel_config_runner.py +278 -0
- symtest/runners/parallel_json_runner.py +26 -0
- symtest/runners/parallel_yaml_runner.py +31 -0
- symtest/runners/yaml_runner.py +26 -0
- symtest/tui/__init__.py +11 -0
- symtest/tui/app.py +90 -0
- symtest/tui/controllers/__init__.py +0 -0
- symtest/tui/controllers/case_controller.py +322 -0
- symtest/tui/screens/__init__.py +0 -0
- symtest/tui/screens/case_editor.py +244 -0
- symtest/tui/screens/case_list.py +255 -0
- symtest/tui/widgets/__init__.py +0 -0
- symtest/tui/widgets/case_table.py +113 -0
- symtest/tui/widgets/expected_editor.py +159 -0
- symtest/tui/widgets/search_bar.py +160 -0
- symtest/tui/widgets/steps_editor.py +243 -0
- symtest/utils/__init__.py +21 -0
- symtest/utils/junit_xml_writer.py +137 -0
- symtest/utils/path_resolver.py +124 -0
- symtest/utils/report_generator.py +208 -0
- symtest_cli-1.3.0.dist-info/METADATA +316 -0
- symtest_cli-1.3.0.dist-info/RECORD +63 -0
- symtest_cli-1.3.0.dist-info/WHEEL +5 -0
- symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
- symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""JSONRunner – thin backward-compatible wrapper around ConfigRunner."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .config_runner import ConfigRunner
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("symtest.runners.json_runner")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JSONRunner(ConfigRunner):
|
|
12
|
+
"""Sequential JSON test runner (backward-compatible thin wrapper)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_file="test_cases.json",
|
|
15
|
+
workspace: Optional[str] = None, **kwargs):
|
|
16
|
+
super().__init__(
|
|
17
|
+
config_file=config_file,
|
|
18
|
+
workspace=workspace,
|
|
19
|
+
config_loader=json.load,
|
|
20
|
+
**kwargs,
|
|
21
|
+
)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Generic parallel config-driven test runner with injectable config loader.
|
|
2
|
+
|
|
3
|
+
The only difference between ParallelJSONRunner and ParallelYAMLRunner was the
|
|
4
|
+
``load`` call. This module extracts the common scheduling / resource-management
|
|
5
|
+
logic into ``ParallelConfigRunner``, accepting a ``config_loader`` callable.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Dict, Any, Callable, BinaryIO
|
|
11
|
+
|
|
12
|
+
from ..core.parallel_runner import ParallelRunner, AtomicSemaphore
|
|
13
|
+
from ..core.config_loader import parse_test_cases, execute_sequence
|
|
14
|
+
from ..config.inheritance_expander import resolve_inheritance, apply_variables
|
|
15
|
+
from ..core.test_case import TestCase
|
|
16
|
+
from ..core.execution import execute_single_test_case
|
|
17
|
+
from ..core.types import TestCaseData
|
|
18
|
+
from ..utils.path_resolver import PathResolver
|
|
19
|
+
from ..config.import_expander import expand_imports
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("symtest.runners.parallel_config_runner")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParallelConfigRunner(ParallelRunner):
|
|
25
|
+
"""Generic parallel test runner with injectable config loader.
|
|
26
|
+
|
|
27
|
+
Subclasses inject a ``config_loader`` instead of hardcoding
|
|
28
|
+
``json.load`` / ``yaml.safe_load``. All scheduling (CPU-aware semaphore,
|
|
29
|
+
heuristic ordering, proportional core assignment) lives here.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config_file: str = "test_cases.json",
|
|
33
|
+
workspace: Optional[str] = None,
|
|
34
|
+
max_workers: Optional[int] = None,
|
|
35
|
+
execution_mode: str = "thread",
|
|
36
|
+
config_loader: Optional[Callable[[BinaryIO], Dict[str, Any]]] = None,
|
|
37
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
38
|
+
**kwargs):
|
|
39
|
+
# Auto-detect physical CPU cores (reserve 2 for OS)
|
|
40
|
+
self.total_physical = os.cpu_count() or 4
|
|
41
|
+
self.safe_capacity = max(1, self.total_physical - 2)
|
|
42
|
+
|
|
43
|
+
if max_workers is None:
|
|
44
|
+
max_workers = self.total_physical
|
|
45
|
+
|
|
46
|
+
super().__init__(config_file, workspace, max_workers,
|
|
47
|
+
execution_mode, **kwargs)
|
|
48
|
+
self._config_loader = config_loader
|
|
49
|
+
self._variables = variables or {}
|
|
50
|
+
# Backward-compatible attribute for tests that patch path_resolver
|
|
51
|
+
self.path_resolver = PathResolver(self.workspace)
|
|
52
|
+
|
|
53
|
+
# Resource pool – only meaningful in thread mode
|
|
54
|
+
self.cpu_semaphore = (
|
|
55
|
+
AtomicSemaphore(self.safe_capacity)
|
|
56
|
+
if execution_mode == "thread" else None
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
logger.info(
|
|
60
|
+
"✅ [Resource Manager] Detected %d CPUs. Pool size set to %d.",
|
|
61
|
+
self.total_physical, self.safe_capacity,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# CPU allocation helpers
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _assign_relative_cpu_cores(self) -> None:
|
|
69
|
+
"""Assign ``cpu_cores`` proportionally based on *estimated_time*
|
|
70
|
+
and *min_memory_mb* for cases without an explicit ``cpu_cores``.
|
|
71
|
+
|
|
72
|
+
Weight = estimated_time (s) + min_memory_mb / 100.
|
|
73
|
+
"""
|
|
74
|
+
candidates = [
|
|
75
|
+
c for c in self.test_cases
|
|
76
|
+
if not (c.resources and "cpu_cores" in c.resources)
|
|
77
|
+
]
|
|
78
|
+
if not candidates:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
def weight(case: TestCase) -> float:
|
|
82
|
+
res = case.resources or {}
|
|
83
|
+
est = float(res.get("estimated_time") or 0)
|
|
84
|
+
mem = float(res.get("min_memory_mb") or 0)
|
|
85
|
+
return est + mem / 100.0
|
|
86
|
+
|
|
87
|
+
weights = [weight(c) for c in candidates]
|
|
88
|
+
total_weight = sum(weights)
|
|
89
|
+
|
|
90
|
+
if total_weight <= 0:
|
|
91
|
+
for case in candidates:
|
|
92
|
+
if not case.resources:
|
|
93
|
+
case.resources = {}
|
|
94
|
+
case.resources["cpu_cores"] = 1
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
allocated = 0
|
|
98
|
+
indexed = sorted(
|
|
99
|
+
enumerate(zip(candidates, weights)),
|
|
100
|
+
key=lambda x: x[1][1], reverse=True,
|
|
101
|
+
)
|
|
102
|
+
for rank, (_idx, (case, w)) in enumerate(indexed):
|
|
103
|
+
if rank == len(indexed) - 1:
|
|
104
|
+
share = max(1, self.safe_capacity - allocated)
|
|
105
|
+
else:
|
|
106
|
+
share = max(
|
|
107
|
+
1, int(round(self.safe_capacity * w / total_weight)),
|
|
108
|
+
)
|
|
109
|
+
if not case.resources:
|
|
110
|
+
case.resources = {}
|
|
111
|
+
case.resources["cpu_cores"] = share
|
|
112
|
+
allocated += share
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Test loading (format-agnostic)
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def load_test_cases(self) -> None:
|
|
119
|
+
"""Load test cases from config file using the injected loader."""
|
|
120
|
+
if self._config_loader is None:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"config_loader must be set before loading test cases"
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
126
|
+
config = self._config_loader(f)
|
|
127
|
+
|
|
128
|
+
# Expand import references (no-op if none present)
|
|
129
|
+
config = expand_imports(config, self.config_path)
|
|
130
|
+
# Resolve extends inheritance (no-op if none present)
|
|
131
|
+
config = resolve_inheritance(config)
|
|
132
|
+
# Per-case + global variable substitution
|
|
133
|
+
config = apply_variables(config, self._variables)
|
|
134
|
+
|
|
135
|
+
self.load_setup_from_config(config)
|
|
136
|
+
self.test_cases = parse_test_cases(
|
|
137
|
+
config, self.workspace, self.path_resolver,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
logger.info("Successfully loaded %d test cases",
|
|
141
|
+
len(self.test_cases))
|
|
142
|
+
|
|
143
|
+
# Heuristic scheduling: longest-estimated first
|
|
144
|
+
if self.test_cases:
|
|
145
|
+
history_cases: Dict[str, Any] = {}
|
|
146
|
+
if self.history_dir:
|
|
147
|
+
from ..core.history_store import load_history
|
|
148
|
+
hist = load_history(self.history_dir)
|
|
149
|
+
history_cases = hist.get("cases", {})
|
|
150
|
+
|
|
151
|
+
def get_estimated_time(case: TestCase) -> float:
|
|
152
|
+
if case.name in history_cases:
|
|
153
|
+
return float(
|
|
154
|
+
history_cases[case.name]["avg_duration"],
|
|
155
|
+
)
|
|
156
|
+
return float(
|
|
157
|
+
(case.resources or {}).get("estimated_time", 0),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info(
|
|
161
|
+
"Optimizing execution order based on estimated duration...",
|
|
162
|
+
)
|
|
163
|
+
self.test_cases.sort(key=get_estimated_time, reverse=True)
|
|
164
|
+
top_case = self.test_cases[0]
|
|
165
|
+
top_est = get_estimated_time(top_case)
|
|
166
|
+
source = (
|
|
167
|
+
"history" if top_case.name in history_cases else "config"
|
|
168
|
+
)
|
|
169
|
+
logger.info(
|
|
170
|
+
"Heaviest task: %s (Est: %.2fs, source: %s)",
|
|
171
|
+
top_case.name, top_est, source,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self._assign_relative_cpu_cores()
|
|
175
|
+
except Exception as e:
|
|
176
|
+
sys.exit(f"Failed to load configuration file: {str(e)}")
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Execution
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _run_sequence(self, case: TestCase) -> Dict[str, Any]:
|
|
183
|
+
"""Run a sequence test case with fail-fast semantics."""
|
|
184
|
+
return execute_sequence(
|
|
185
|
+
case_name=case.name,
|
|
186
|
+
steps=case.steps,
|
|
187
|
+
workspace=str(self.workspace) if self.workspace else None,
|
|
188
|
+
print_prefix="[Worker]",
|
|
189
|
+
case_expected=case.expected if case.expected else None,
|
|
190
|
+
update_baseline=self.update_baseline,
|
|
191
|
+
error_analysis=self.error_analysis,
|
|
192
|
+
resume=self.resume,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def run_single_test(self, case: TestCase) -> Dict[str, Any]:
|
|
196
|
+
"""Thread-safe, resource-aware execution of a single test case.
|
|
197
|
+
|
|
198
|
+
In *thread* mode this uses an ``AtomicSemaphore`` to cap concurrent
|
|
199
|
+
CPU core usage and sets ``OMP_NUM_THREADS`` / ``MKL_NUM_THREADS``
|
|
200
|
+
environment variables accordingly.
|
|
201
|
+
"""
|
|
202
|
+
# 1. Determine required core count
|
|
203
|
+
required_cores = 1
|
|
204
|
+
if case.resources and "cpu_cores" in case.resources:
|
|
205
|
+
required_cores = case.resources["cpu_cores"]
|
|
206
|
+
|
|
207
|
+
if required_cores > self.safe_capacity:
|
|
208
|
+
required_cores = self.safe_capacity
|
|
209
|
+
|
|
210
|
+
tokens_acquired = 0
|
|
211
|
+
task_env = None
|
|
212
|
+
|
|
213
|
+
# 2. Acquire resource tokens (thread mode only)
|
|
214
|
+
if self.execution_mode == "thread" and self.cpu_semaphore is not None:
|
|
215
|
+
if not self.cpu_semaphore.acquire(required_cores, timeout=10.0):
|
|
216
|
+
required_cores = 1
|
|
217
|
+
self.cpu_semaphore.acquire(1)
|
|
218
|
+
tokens_acquired = 1
|
|
219
|
+
else:
|
|
220
|
+
tokens_acquired = required_cores
|
|
221
|
+
|
|
222
|
+
task_env = {
|
|
223
|
+
"OMP_NUM_THREADS": str(required_cores),
|
|
224
|
+
"MKL_NUM_THREADS": str(required_cores),
|
|
225
|
+
"NPROC": str(required_cores),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
" [Scheduler] Task '%s' acquired %d cores. Running...",
|
|
230
|
+
case.name, tokens_acquired,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# 3. Execute
|
|
234
|
+
if case.steps:
|
|
235
|
+
result = self._run_sequence(case)
|
|
236
|
+
else:
|
|
237
|
+
case_data = case.to_execution_dict()
|
|
238
|
+
|
|
239
|
+
command_preview = (
|
|
240
|
+
f"{case_data['command']} {' '.join(case_data['args'])}".strip()
|
|
241
|
+
)
|
|
242
|
+
if self.execution_mode != "thread" or self.cpu_semaphore is None:
|
|
243
|
+
logger.info(
|
|
244
|
+
" [Worker] Executing command: %s", command_preview,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
result = execute_single_test_case(
|
|
248
|
+
case_data,
|
|
249
|
+
str(self.workspace) if self.workspace else None,
|
|
250
|
+
env=task_env,
|
|
251
|
+
update_baseline=self.update_baseline,
|
|
252
|
+
error_analysis=self.error_analysis,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if result["output"].strip():
|
|
256
|
+
logger.debug(
|
|
257
|
+
" [Worker] Command output for %s:", case.name,
|
|
258
|
+
)
|
|
259
|
+
for line in result["output"].splitlines():
|
|
260
|
+
logger.debug(" %s", line)
|
|
261
|
+
|
|
262
|
+
if result["status"] != "passed" and result.get("message"):
|
|
263
|
+
logger.error(
|
|
264
|
+
" [Worker] Error for %s: %s",
|
|
265
|
+
case.name, result["message"],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# 4. Release tokens
|
|
269
|
+
if (self.execution_mode == "thread"
|
|
270
|
+
and self.cpu_semaphore is not None
|
|
271
|
+
and tokens_acquired > 0):
|
|
272
|
+
self.cpu_semaphore.release(tokens_acquired)
|
|
273
|
+
logger.info(
|
|
274
|
+
" [Scheduler] Task '%s' released %d cores.",
|
|
275
|
+
case.name, tokens_acquired,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return result
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""ParallelJSONRunner – thin backward-compatible wrapper around ParallelConfigRunner."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .parallel_config_runner import ParallelConfigRunner
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("symtest.runners.parallel_json_runner")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParallelJSONRunner(ParallelConfigRunner):
|
|
12
|
+
"""Parallel JSON test runner (backward-compatible thin wrapper)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_file="test_cases.json",
|
|
15
|
+
workspace: Optional[str] = None,
|
|
16
|
+
max_workers: Optional[int] = None,
|
|
17
|
+
execution_mode: str = "thread",
|
|
18
|
+
**kwargs):
|
|
19
|
+
super().__init__(
|
|
20
|
+
config_file=config_file,
|
|
21
|
+
workspace=workspace,
|
|
22
|
+
max_workers=max_workers,
|
|
23
|
+
execution_mode=execution_mode,
|
|
24
|
+
config_loader=json.load,
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ParallelYAMLRunner – thin backward-compatible wrapper around ParallelConfigRunner."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .parallel_config_runner import ParallelConfigRunner
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("symtest.runners.parallel_yaml_runner")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _yaml_load(f):
|
|
11
|
+
"""Lazy-load yaml.safe_load so the package is importable without PyYAML."""
|
|
12
|
+
import yaml
|
|
13
|
+
return yaml.safe_load(f)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ParallelYAMLRunner(ParallelConfigRunner):
|
|
17
|
+
"""Parallel YAML test runner (backward-compatible thin wrapper)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config_file="test_cases.yaml",
|
|
20
|
+
workspace: Optional[str] = None,
|
|
21
|
+
max_workers: Optional[int] = None,
|
|
22
|
+
execution_mode: str = "thread",
|
|
23
|
+
**kwargs):
|
|
24
|
+
super().__init__(
|
|
25
|
+
config_file=config_file,
|
|
26
|
+
workspace=workspace,
|
|
27
|
+
max_workers=max_workers,
|
|
28
|
+
execution_mode=execution_mode,
|
|
29
|
+
config_loader=_yaml_load,
|
|
30
|
+
**kwargs,
|
|
31
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""YAMLRunner – thin backward-compatible wrapper around ConfigRunner."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .config_runner import ConfigRunner
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("symtest.runners.yaml_runner")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _yaml_load(f):
|
|
11
|
+
"""Lazy-load yaml.safe_load so the package is importable without PyYAML."""
|
|
12
|
+
import yaml
|
|
13
|
+
return yaml.safe_load(f)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class YAMLRunner(ConfigRunner):
|
|
17
|
+
"""Sequential YAML test runner (backward-compatible thin wrapper)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config_file="test_cases.yaml",
|
|
20
|
+
workspace: Optional[str] = None, **kwargs):
|
|
21
|
+
super().__init__(
|
|
22
|
+
config_file=config_file,
|
|
23
|
+
workspace=workspace,
|
|
24
|
+
config_loader=_yaml_load,
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
symtest/tui/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TUI Manager for interactive test case CRUD.
|
|
3
|
+
|
|
4
|
+
Provides a Textual-based terminal UI for browsing, editing and running
|
|
5
|
+
test cases. Relies on :mod:`symtest.config.config_io` for
|
|
6
|
+
configuration I/O and :mod:`symtest.core` for execution.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
symtest tui test_cases.json
|
|
11
|
+
"""
|
symtest/tui/app.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Textual App entry point for the TUI manager.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from symtest.tui.app import run_tui
|
|
6
|
+
run_tui("test_cases.json", workspace="/path/to/project")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
|
|
17
|
+
from .controllers.case_controller import CaseController
|
|
18
|
+
from .screens.case_list import CaseListScreen
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CaseManagerApp(App):
|
|
22
|
+
"""Terminal UI for browsing / editing / running test cases."""
|
|
23
|
+
|
|
24
|
+
TITLE = "CLI Test Framework - Case Manager"
|
|
25
|
+
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
|
|
28
|
+
Binding("q", "quit", "Quit", show=True, priority=True),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
CSS = """
|
|
32
|
+
Screen {
|
|
33
|
+
background: $surface;
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config_file: str, workspace: Optional[str] = None,
|
|
38
|
+
update_baseline: bool = False,
|
|
39
|
+
history_dir: Optional[str] = None,
|
|
40
|
+
update_history: bool = False):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self._config_file = config_file
|
|
43
|
+
self._workspace = workspace
|
|
44
|
+
self._controller = CaseController()
|
|
45
|
+
self._controller.update_baseline = update_baseline
|
|
46
|
+
self._controller.history_dir = history_dir
|
|
47
|
+
self._controller.update_history = update_history
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
try:
|
|
51
|
+
count = self._controller.load(self._config_file, self._workspace)
|
|
52
|
+
self.notify(f"Loaded {count} test cases from {Path(self._config_file).name}")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
self.notify(f"Failed to load config: {e}", severity="error")
|
|
55
|
+
|
|
56
|
+
self.push_screen(CaseListScreen(self._controller))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Public entry point
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_tui(config_file: str, workspace: Optional[str] = None,
|
|
65
|
+
update_baseline: bool = False,
|
|
66
|
+
history_dir: Optional[str] = None,
|
|
67
|
+
update_history: bool = False) -> None:
|
|
68
|
+
"""Launch the TUI manager.
|
|
69
|
+
|
|
70
|
+
Safe to call without installing textual — raises :exc:`ImportError`
|
|
71
|
+
with a friendly message if textual is not available.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
import textual # noqa: F401 — verify textual is importable
|
|
75
|
+
except ImportError:
|
|
76
|
+
import sys
|
|
77
|
+
|
|
78
|
+
print(
|
|
79
|
+
"TUI 功能需要安装 textual。请运行:\n"
|
|
80
|
+
" pip install symtest[tui]\n"
|
|
81
|
+
"或:\n"
|
|
82
|
+
" pip install textual"
|
|
83
|
+
)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
app = CaseManagerApp(config_file, workspace,
|
|
87
|
+
update_baseline=update_baseline,
|
|
88
|
+
history_dir=history_dir,
|
|
89
|
+
update_history=update_history)
|
|
90
|
+
app.run()
|
|
File without changes
|