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,322 @@
|
|
|
1
|
+
"""Bridge between config I/O and the TUI: CRUD, search, run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from ...core.test_case import TestCase, TestCaseStep
|
|
12
|
+
from ...core.execution import execute_single_test_case
|
|
13
|
+
from ...core.config_loader import parse_test_cases, execute_sequence
|
|
14
|
+
from ...config.config_io import load_config, save_config
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("symtest.tui.controller")
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Search helpers
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
# Field weights for fuzzy scoring (higher = more important).
|
|
23
|
+
_FIELD_WEIGHTS: Dict[str, float] = {
|
|
24
|
+
"name": 2.0,
|
|
25
|
+
"command": 1.5,
|
|
26
|
+
"tags": 1.0,
|
|
27
|
+
"description": 1.0,
|
|
28
|
+
"args": 0.5,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Default searchable fields and their extractors
|
|
32
|
+
_SEARCH_FIELDS = [
|
|
33
|
+
("name", lambda tc: tc.name),
|
|
34
|
+
("command", lambda tc: tc.command),
|
|
35
|
+
("args", lambda tc: " ".join(tc.args)),
|
|
36
|
+
("tags", lambda tc: ",".join(tc.tags)),
|
|
37
|
+
("description", lambda tc: tc.description or ""),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _substring_match(query: str, case: TestCase) -> bool:
|
|
42
|
+
"""Case-insensitive substring match across all searchable fields."""
|
|
43
|
+
q = query.lower()
|
|
44
|
+
for _field_name, extractor in _SEARCH_FIELDS:
|
|
45
|
+
if q in extractor(case).lower():
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _regex_match(query: str, case: TestCase) -> bool:
|
|
51
|
+
"""Regex search across all searchable fields (case-insensitive)."""
|
|
52
|
+
try:
|
|
53
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
54
|
+
except re.error:
|
|
55
|
+
return False
|
|
56
|
+
for _field_name, extractor in _SEARCH_FIELDS:
|
|
57
|
+
if pattern.search(extractor(case)):
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fuzzy_score(query: str, case: TestCase) -> float:
|
|
63
|
+
"""N-gram overlap scorer; higher = better match."""
|
|
64
|
+
q = query.lower()
|
|
65
|
+
q_bigrams = {q[i : i + 2] for i in range(len(q) - 1)} if len(q) >= 2 else {q}
|
|
66
|
+
if not q_bigrams:
|
|
67
|
+
return 0.0
|
|
68
|
+
|
|
69
|
+
total = 0.0
|
|
70
|
+
for field_name, extractor in _SEARCH_FIELDS:
|
|
71
|
+
text = extractor(case).lower()
|
|
72
|
+
text_bigrams = {text[i : i + 2] for i in range(len(text) - 1)}
|
|
73
|
+
if not text_bigrams:
|
|
74
|
+
continue
|
|
75
|
+
overlap = len(q_bigrams & text_bigrams) / len(q_bigrams)
|
|
76
|
+
total += _FIELD_WEIGHTS.get(field_name, 0.5) * overlap
|
|
77
|
+
return total
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fuzzy_match(query: str, cases: List[TestCase], threshold: float = 0.15) -> List[int]:
|
|
81
|
+
"""Return indices of cases whose fuzzy score meets *threshold*."""
|
|
82
|
+
scored: List[Tuple[int, float]] = []
|
|
83
|
+
for i, tc in enumerate(cases):
|
|
84
|
+
s = _fuzzy_score(query, tc)
|
|
85
|
+
if s >= threshold:
|
|
86
|
+
scored.append((i, s))
|
|
87
|
+
scored.sort(key=lambda x: -x[1])
|
|
88
|
+
return [idx for idx, _ in scored]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# CaseController
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CaseController:
|
|
97
|
+
"""Holds test cases in memory and bridges config_io ↔ TUI."""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
self._cases: List[TestCase] = []
|
|
101
|
+
self._file_path: Optional[Path] = None
|
|
102
|
+
self._workspace: Optional[str] = None
|
|
103
|
+
self._dirty = False
|
|
104
|
+
self._setup: Dict[str, Any] = {}
|
|
105
|
+
self._update_baseline: bool = False
|
|
106
|
+
self._history_dir: Optional[str] = None
|
|
107
|
+
self._update_history: bool = False
|
|
108
|
+
|
|
109
|
+
# -- properties ----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def cases(self) -> List[TestCase]:
|
|
113
|
+
return self._cases
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def file_path(self) -> Optional[Path]:
|
|
117
|
+
return self._file_path
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def file_name(self) -> str:
|
|
121
|
+
return self._file_path.name if self._file_path else "(untitled)"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def workspace(self) -> Optional[str]:
|
|
125
|
+
return self._workspace
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def dirty(self) -> bool:
|
|
129
|
+
return self._dirty
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def case_count(self) -> int:
|
|
133
|
+
return len(self._cases)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def update_baseline(self) -> bool:
|
|
137
|
+
return self._update_baseline
|
|
138
|
+
|
|
139
|
+
@update_baseline.setter
|
|
140
|
+
def update_baseline(self, value: bool) -> None:
|
|
141
|
+
self._update_baseline = value
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def history_dir(self) -> Optional[str]:
|
|
145
|
+
return self._history_dir
|
|
146
|
+
|
|
147
|
+
@history_dir.setter
|
|
148
|
+
def history_dir(self, value: Optional[str]) -> None:
|
|
149
|
+
self._history_dir = value
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def update_history(self) -> bool:
|
|
153
|
+
return self._update_history
|
|
154
|
+
|
|
155
|
+
@update_history.setter
|
|
156
|
+
def update_history(self, value: bool) -> None:
|
|
157
|
+
self._update_history = value
|
|
158
|
+
|
|
159
|
+
# -- load / save ---------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def load(self, file_path: str, workspace: Optional[str] = None) -> int:
|
|
162
|
+
"""Load config from *file_path*, parse test cases, return case count."""
|
|
163
|
+
path = Path(file_path).resolve()
|
|
164
|
+
config = load_config(path)
|
|
165
|
+
|
|
166
|
+
# Store setup for later re-serialisation
|
|
167
|
+
self._setup = config.get("setup", {})
|
|
168
|
+
self._file_path = path
|
|
169
|
+
self._workspace = workspace
|
|
170
|
+
|
|
171
|
+
# Parse test cases via unified entry point (skip path resolution for TUI display)
|
|
172
|
+
self._cases = parse_test_cases(config)
|
|
173
|
+
self._dirty = False
|
|
174
|
+
return len(self._cases)
|
|
175
|
+
|
|
176
|
+
def save(self, file_path: Optional[str] = None) -> None:
|
|
177
|
+
"""Persist cases to the current file (or *file_path* if given)."""
|
|
178
|
+
target = Path(file_path) if file_path else self._file_path
|
|
179
|
+
if target is None:
|
|
180
|
+
raise ValueError("No file path set for save.")
|
|
181
|
+
|
|
182
|
+
config: Dict[str, Any] = {"test_cases": []}
|
|
183
|
+
if self._setup:
|
|
184
|
+
config["setup"] = self._setup
|
|
185
|
+
config["test_cases"] = [tc.to_dict() for tc in self._cases]
|
|
186
|
+
|
|
187
|
+
save_config(config, target)
|
|
188
|
+
|
|
189
|
+
if file_path:
|
|
190
|
+
self._file_path = Path(file_path).resolve()
|
|
191
|
+
|
|
192
|
+
self._dirty = False
|
|
193
|
+
|
|
194
|
+
def save_as(self, file_path: str) -> None:
|
|
195
|
+
"""Save to a different file."""
|
|
196
|
+
self.save(file_path=file_path)
|
|
197
|
+
|
|
198
|
+
# -- CRUD ----------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def get_case(self, index: int) -> TestCase:
|
|
201
|
+
return self._cases[index]
|
|
202
|
+
|
|
203
|
+
def add_case(self, case: TestCase) -> int:
|
|
204
|
+
"""Append *case*; return its index."""
|
|
205
|
+
self._cases.append(case)
|
|
206
|
+
self._dirty = True
|
|
207
|
+
return len(self._cases) - 1
|
|
208
|
+
|
|
209
|
+
def update_case(self, index: int, case: TestCase) -> None:
|
|
210
|
+
self._cases[index] = case
|
|
211
|
+
self._dirty = True
|
|
212
|
+
|
|
213
|
+
def delete_case(self, index: int) -> None:
|
|
214
|
+
del self._cases[index]
|
|
215
|
+
self._dirty = True
|
|
216
|
+
|
|
217
|
+
def duplicate_case(self, index: int) -> int:
|
|
218
|
+
"""Deep-copy case at *index*, append '_copy' to name."""
|
|
219
|
+
original = self._cases[index]
|
|
220
|
+
new_case = copy.deepcopy(original)
|
|
221
|
+
new_case.name = original.name + "_copy"
|
|
222
|
+
return self.add_case(new_case)
|
|
223
|
+
|
|
224
|
+
def move_case(self, from_idx: int, to_idx: int) -> None:
|
|
225
|
+
"""Move case from *from_idx* to *to_idx*."""
|
|
226
|
+
if from_idx == to_idx:
|
|
227
|
+
return
|
|
228
|
+
case = self._cases.pop(from_idx)
|
|
229
|
+
self._cases.insert(to_idx, case)
|
|
230
|
+
self._dirty = True
|
|
231
|
+
|
|
232
|
+
def swap_cases(self, i: int, j: int) -> None:
|
|
233
|
+
self._cases[i], self._cases[j] = self._cases[j], self._cases[i]
|
|
234
|
+
self._dirty = True
|
|
235
|
+
|
|
236
|
+
# -- search --------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def search(
|
|
239
|
+
self,
|
|
240
|
+
query: str,
|
|
241
|
+
mode: str = "substring",
|
|
242
|
+
tag: Optional[str] = None,
|
|
243
|
+
) -> List[int]:
|
|
244
|
+
"""Return sorted list of indices for cases matching *query*.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
query:
|
|
249
|
+
Search term.
|
|
250
|
+
mode:
|
|
251
|
+
``"substring"`` (default), ``"fuzzy"``, or ``"regex"``.
|
|
252
|
+
tag:
|
|
253
|
+
Optional tag filter (exact match).
|
|
254
|
+
"""
|
|
255
|
+
if mode == "fuzzy":
|
|
256
|
+
indices = _fuzzy_match(query, self._cases)
|
|
257
|
+
elif mode == "regex":
|
|
258
|
+
indices = [i for i, tc in enumerate(self._cases) if _regex_match(query, tc)]
|
|
259
|
+
else: # substring (default)
|
|
260
|
+
indices = [i for i, tc in enumerate(self._cases) if _substring_match(query, tc)]
|
|
261
|
+
|
|
262
|
+
# Apply tag filter
|
|
263
|
+
if tag:
|
|
264
|
+
indices = [i for i in indices if tag in self._cases[i].tags]
|
|
265
|
+
|
|
266
|
+
return indices
|
|
267
|
+
|
|
268
|
+
def get_all_tags(self) -> List[str]:
|
|
269
|
+
"""Collect unique tags across all cases, sorted."""
|
|
270
|
+
tags: set = set()
|
|
271
|
+
for tc in self._cases:
|
|
272
|
+
tags.update(tc.tags)
|
|
273
|
+
return sorted(tags)
|
|
274
|
+
|
|
275
|
+
# -- run ----------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def run_case(self, index: int) -> Dict[str, Any]:
|
|
278
|
+
"""Execute a single test case and return the result dict."""
|
|
279
|
+
case = self._cases[index]
|
|
280
|
+
if case.steps:
|
|
281
|
+
# Sequence mode — use execute_sequence (consistent with Runner)
|
|
282
|
+
result = execute_sequence(
|
|
283
|
+
case_name=case.name,
|
|
284
|
+
steps=case.steps,
|
|
285
|
+
workspace=self._workspace,
|
|
286
|
+
update_baseline=self._update_baseline,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
# Single-command mode — unified to_execution_dict()
|
|
290
|
+
result = execute_single_test_case(
|
|
291
|
+
case.to_execution_dict(),
|
|
292
|
+
self._workspace,
|
|
293
|
+
update_baseline=self._update_baseline,
|
|
294
|
+
)
|
|
295
|
+
self._record_history(case.name, result)
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
def _record_history(self, case_name: str, result: Dict[str, Any]) -> None:
|
|
299
|
+
"""Per-case history recording with optional reset support."""
|
|
300
|
+
if not self._history_dir:
|
|
301
|
+
return
|
|
302
|
+
from pathlib import Path as _Path
|
|
303
|
+
from ...core.history_store import load_history, save_history, reset_cases, update_case
|
|
304
|
+
|
|
305
|
+
history_dir = str((_Path(self._workspace or ".") / self._history_dir).resolve())
|
|
306
|
+
history = load_history(history_dir)
|
|
307
|
+
|
|
308
|
+
if self._update_history:
|
|
309
|
+
cleared = reset_cases(history, {case_name})
|
|
310
|
+
if cleared:
|
|
311
|
+
logger.info("History reset: cleared '%s'", case_name)
|
|
312
|
+
|
|
313
|
+
if result.get("status") == "passed":
|
|
314
|
+
update_case(history, case_name, result.get("duration", 0))
|
|
315
|
+
save_history(history_dir, history)
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def create_empty_case(mode: str = "single") -> TestCase:
|
|
319
|
+
"""Create a blank TestCase."""
|
|
320
|
+
if mode == "sequence":
|
|
321
|
+
return TestCase(name="new_case", steps=[])
|
|
322
|
+
return TestCase(name="new_case", command="", args=[], expected={}, tags=[])
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Screen for editing a single test case (both single-cmd and sequence modes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.screen import Screen
|
|
7
|
+
from textual.widgets import Input, TextArea, Static, Button, Header, Footer, Label
|
|
8
|
+
from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
|
|
11
|
+
from ...core.test_case import TestCase
|
|
12
|
+
from ..widgets.expected_editor import ExpectedEditor
|
|
13
|
+
from ..widgets.steps_editor import StepsEditor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CaseEditorScreen(Screen):
|
|
17
|
+
"""Full-screen form for creating / editing a test case."""
|
|
18
|
+
|
|
19
|
+
DEFAULT_CSS = """
|
|
20
|
+
CaseEditorScreen {
|
|
21
|
+
align: center middle;
|
|
22
|
+
}
|
|
23
|
+
CaseEditorScreen #editor-container {
|
|
24
|
+
width: 90%;
|
|
25
|
+
height: 90%;
|
|
26
|
+
border: solid $primary;
|
|
27
|
+
padding: 1 2;
|
|
28
|
+
}
|
|
29
|
+
CaseEditorScreen #editor-fields {
|
|
30
|
+
height: 1fr;
|
|
31
|
+
}
|
|
32
|
+
CaseEditorScreen #editor-footer {
|
|
33
|
+
height: auto;
|
|
34
|
+
margin-top: 1;
|
|
35
|
+
align-horizontal: right;
|
|
36
|
+
}
|
|
37
|
+
CaseEditorScreen Input {
|
|
38
|
+
width: 100%;
|
|
39
|
+
margin-bottom: 1;
|
|
40
|
+
}
|
|
41
|
+
CaseEditorScreen Label {
|
|
42
|
+
margin-top: 1;
|
|
43
|
+
}
|
|
44
|
+
CaseEditorScreen #case-tags {
|
|
45
|
+
width: 100%;
|
|
46
|
+
}
|
|
47
|
+
CaseEditorScreen #case-description {
|
|
48
|
+
height: 3;
|
|
49
|
+
margin-bottom: 1;
|
|
50
|
+
}
|
|
51
|
+
CaseEditorScreen #case-timeout {
|
|
52
|
+
width: 15;
|
|
53
|
+
}
|
|
54
|
+
CaseEditorScreen #mode-indicator {
|
|
55
|
+
color: $accent;
|
|
56
|
+
text-style: bold;
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
class Saved(Message):
|
|
61
|
+
"""Emitted when user saves, carrying the TestCase."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, case: TestCase):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.case = case
|
|
66
|
+
|
|
67
|
+
class Cancelled(Message):
|
|
68
|
+
"""Emitted when user cancels editing."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, case: TestCase | None = None):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self._case: TestCase | None = case
|
|
73
|
+
self._is_sequence = case is not None and case.steps is not None
|
|
74
|
+
|
|
75
|
+
def compose(self):
|
|
76
|
+
yield Header()
|
|
77
|
+
yield Vertical(
|
|
78
|
+
ScrollableContainer(
|
|
79
|
+
Label("Name:", id="name-label"),
|
|
80
|
+
Input(placeholder="test_case_name", id="case-name"),
|
|
81
|
+
Label("Mode:", id="mode-label"),
|
|
82
|
+
Static("Single Command", id="mode-indicator"),
|
|
83
|
+
Label("Command:", id="cmd-label"),
|
|
84
|
+
Input(placeholder="python", id="case-command"),
|
|
85
|
+
Label("Args (space-separated):", id="args-label"),
|
|
86
|
+
Input(placeholder="./script.py --flag", id="case-args"),
|
|
87
|
+
Label("Tags (comma-separated):", id="tags-label"),
|
|
88
|
+
Input(placeholder="tag1, tag2", id="case-tags"),
|
|
89
|
+
Label("Description:", id="desc-label"),
|
|
90
|
+
TextArea("", id="case-description"),
|
|
91
|
+
Label("Timeout (seconds):", id="timeout-label"),
|
|
92
|
+
Input(placeholder="", id="case-timeout"),
|
|
93
|
+
Label("Retry count:", id="retry-label"),
|
|
94
|
+
Input(placeholder="0", id="case-retry-count"),
|
|
95
|
+
ExpectedEditor(id="expected-editor"),
|
|
96
|
+
StepsEditor(id="steps-editor"),
|
|
97
|
+
id="editor-fields",
|
|
98
|
+
),
|
|
99
|
+
Horizontal(
|
|
100
|
+
Button("Save", id="btn-save", variant="primary"),
|
|
101
|
+
Button("Cancel", id="btn-cancel"),
|
|
102
|
+
Button("Switch to Sequence", id="btn-switch-mode"),
|
|
103
|
+
id="editor-footer",
|
|
104
|
+
),
|
|
105
|
+
id="editor-container",
|
|
106
|
+
)
|
|
107
|
+
yield Footer()
|
|
108
|
+
|
|
109
|
+
def on_mount(self) -> None:
|
|
110
|
+
if self._case is None:
|
|
111
|
+
self._case = TestCase(name="new_case", command="", args=[], expected={}, tags=[])
|
|
112
|
+
self._is_sequence = False
|
|
113
|
+
else:
|
|
114
|
+
self._is_sequence = self._case.steps is not None
|
|
115
|
+
|
|
116
|
+
self._populate_form()
|
|
117
|
+
self._update_mode_visibility()
|
|
118
|
+
|
|
119
|
+
# -- populate / collect --------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _populate_form(self) -> None:
|
|
122
|
+
if self._case is None:
|
|
123
|
+
return
|
|
124
|
+
tc = self._case
|
|
125
|
+
|
|
126
|
+
self.query_one("#case-name", Input).value = tc.name
|
|
127
|
+
self.query_one("#case-command", Input).value = tc.command
|
|
128
|
+
self.query_one("#case-args", Input).value = " ".join(tc.args) if tc.args else ""
|
|
129
|
+
self.query_one("#case-tags", Input).value = ",".join(tc.tags) if tc.tags else ""
|
|
130
|
+
self.query_one("#case-description", TextArea).text = tc.description or ""
|
|
131
|
+
self.query_one("#case-timeout", Input).value = str(tc.timeout) if tc.timeout else ""
|
|
132
|
+
self.query_one("#case-retry-count", Input).value = str(tc.retry_count) if tc.retry_count else ""
|
|
133
|
+
|
|
134
|
+
self.query_one("#expected-editor", ExpectedEditor).load(tc.expected or {})
|
|
135
|
+
|
|
136
|
+
if self._is_sequence and tc.steps:
|
|
137
|
+
self.query_one("#steps-editor", StepsEditor).load(tc.steps)
|
|
138
|
+
|
|
139
|
+
self.query_one("#mode-indicator", Static).update(
|
|
140
|
+
"Sequence Mode" if self._is_sequence else "Single Command"
|
|
141
|
+
)
|
|
142
|
+
switch_btn = self.query_one("#btn-switch-mode", Button)
|
|
143
|
+
switch_btn.label = "Switch to Sequence" if not self._is_sequence else "Switch to Single"
|
|
144
|
+
|
|
145
|
+
def _collect_case(self) -> TestCase:
|
|
146
|
+
name = self.query_one("#case-name", Input).value.strip() or "unnamed"
|
|
147
|
+
tags_text = self.query_one("#case-tags", Input).value
|
|
148
|
+
tags = [t.strip() for t in tags_text.split(",") if t.strip()]
|
|
149
|
+
|
|
150
|
+
timeout_text = self.query_one("#case-timeout", Input).value.strip()
|
|
151
|
+
timeout = float(timeout_text) if timeout_text else None
|
|
152
|
+
|
|
153
|
+
retry_text = self.query_one("#case-retry-count", Input).value.strip()
|
|
154
|
+
retry_count = int(retry_text) if retry_text else 0
|
|
155
|
+
|
|
156
|
+
description = self.query_one("#case-description", TextArea).text.strip()
|
|
157
|
+
|
|
158
|
+
if self._is_sequence:
|
|
159
|
+
steps = self.query_one("#steps-editor", StepsEditor).to_steps()
|
|
160
|
+
expected = self.query_one("#expected-editor", ExpectedEditor).to_dict()
|
|
161
|
+
return TestCase(
|
|
162
|
+
name=name,
|
|
163
|
+
steps=steps,
|
|
164
|
+
expected=expected,
|
|
165
|
+
description=description or "",
|
|
166
|
+
timeout=timeout,
|
|
167
|
+
resources=self._case.resources if self._case else None,
|
|
168
|
+
tags=tags,
|
|
169
|
+
retry_count=retry_count,
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
command = self.query_one("#case-command", Input).value.strip()
|
|
173
|
+
args_text = self.query_one("#case-args", Input).value.strip()
|
|
174
|
+
args = args_text.split() if args_text else []
|
|
175
|
+
expected = self.query_one("#expected-editor", ExpectedEditor).to_dict()
|
|
176
|
+
|
|
177
|
+
return TestCase(
|
|
178
|
+
name=name,
|
|
179
|
+
command=command,
|
|
180
|
+
args=args,
|
|
181
|
+
expected=expected,
|
|
182
|
+
description=description or "",
|
|
183
|
+
timeout=timeout,
|
|
184
|
+
resources=self._case.resources if self._case else None,
|
|
185
|
+
tags=tags,
|
|
186
|
+
retry_count=retry_count,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _update_mode_visibility(self) -> None:
|
|
190
|
+
"""Show/hide fields based on current mode."""
|
|
191
|
+
cmd_label = self.query_one("#cmd-label", Label)
|
|
192
|
+
args_label = self.query_one("#args-label", Label)
|
|
193
|
+
cmd_input = self.query_one("#case-command", Input)
|
|
194
|
+
args_input = self.query_one("#case-args", Input)
|
|
195
|
+
expected = self.query_one("#expected-editor", ExpectedEditor)
|
|
196
|
+
steps = self.query_one("#steps-editor", StepsEditor)
|
|
197
|
+
mode_label = self.query_one("#mode-label", Label)
|
|
198
|
+
mode_indicator = self.query_one("#mode-indicator", Static)
|
|
199
|
+
switch_btn = self.query_one("#btn-switch-mode", Button)
|
|
200
|
+
|
|
201
|
+
if self._is_sequence:
|
|
202
|
+
mode_label.display = True
|
|
203
|
+
mode_indicator.display = True
|
|
204
|
+
cmd_label.display = False
|
|
205
|
+
args_label.display = False
|
|
206
|
+
cmd_input.display = False
|
|
207
|
+
args_input.display = False
|
|
208
|
+
expected.display = True
|
|
209
|
+
steps.display = True
|
|
210
|
+
switch_btn.label = "Switch to Single"
|
|
211
|
+
else:
|
|
212
|
+
mode_label.display = False
|
|
213
|
+
mode_indicator.display = True
|
|
214
|
+
cmd_label.display = True
|
|
215
|
+
args_label.display = True
|
|
216
|
+
cmd_input.display = True
|
|
217
|
+
args_input.display = True
|
|
218
|
+
expected.display = True
|
|
219
|
+
steps.display = False
|
|
220
|
+
switch_btn.label = "Switch to Sequence"
|
|
221
|
+
|
|
222
|
+
# -- button handlers -----------------------------------------------------
|
|
223
|
+
|
|
224
|
+
@on(Button.Pressed, "#btn-save")
|
|
225
|
+
def _on_save(self) -> None:
|
|
226
|
+
try:
|
|
227
|
+
case = self._collect_case()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self.notify(f"Error: {e}", severity="error")
|
|
230
|
+
return
|
|
231
|
+
self.post_message(self.Saved(case))
|
|
232
|
+
self.dismiss()
|
|
233
|
+
|
|
234
|
+
@on(Button.Pressed, "#btn-cancel")
|
|
235
|
+
def _on_cancel(self) -> None:
|
|
236
|
+
self.post_message(self.Cancelled())
|
|
237
|
+
self.dismiss()
|
|
238
|
+
|
|
239
|
+
@on(Button.Pressed, "#btn-switch-mode")
|
|
240
|
+
def _on_switch_mode(self) -> None:
|
|
241
|
+
self._is_sequence = not self._is_sequence
|
|
242
|
+
mode_label = self.query_one("#mode-indicator", Static)
|
|
243
|
+
mode_label.update("Sequence Mode" if self._is_sequence else "Single Command")
|
|
244
|
+
self._update_mode_visibility()
|