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,255 @@
|
|
|
1
|
+
"""Main screen: case table + search bar + status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.screen import Screen
|
|
6
|
+
from textual.widgets import Header, Footer, Static
|
|
7
|
+
from textual.containers import Horizontal, Container
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
|
|
10
|
+
from ..controllers.case_controller import CaseController
|
|
11
|
+
from ..widgets.case_table import CaseTable
|
|
12
|
+
from ..widgets.search_bar import SearchBar
|
|
13
|
+
from .case_editor import CaseEditorScreen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CaseListScreen(Screen):
|
|
17
|
+
"""Primary TUI screen showing the test case table."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("/", "focus_search", "Search", show=True),
|
|
21
|
+
Binding("escape", "clear_search", "Clear search", show=True),
|
|
22
|
+
Binding("a", "add_case", "Add", show=True),
|
|
23
|
+
Binding("e", "edit_case", "Edit", show=True),
|
|
24
|
+
Binding("d", "delete_case", "Delete", show=True),
|
|
25
|
+
Binding("u", "duplicate_case", "Dup", show=True),
|
|
26
|
+
Binding("r", "run_case", "Run", show=True),
|
|
27
|
+
Binding("f6", "save_file", "Save", show=True),
|
|
28
|
+
Binding("ctrl+s", "save_file", "Save", show=False),
|
|
29
|
+
Binding("ctrl+q", "quit", "Quit", show=True),
|
|
30
|
+
Binding("q", "quit", "Quit", show=True),
|
|
31
|
+
Binding("n", "next_match", "Next", show=False),
|
|
32
|
+
Binding("shift+enter", "prev_match", "Prev", show=False),
|
|
33
|
+
Binding("up", "cursor_up", "", show=False),
|
|
34
|
+
Binding("down", "cursor_down", "", show=False),
|
|
35
|
+
Binding("alt+f", "toggle_fuzzy", "", show=False),
|
|
36
|
+
Binding("alt+r", "toggle_regex", "", show=False),
|
|
37
|
+
Binding("alt+s", "toggle_substring", "", show=False),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
DEFAULT_CSS = """
|
|
41
|
+
CaseListScreen {
|
|
42
|
+
align: center middle;
|
|
43
|
+
}
|
|
44
|
+
CaseListScreen #status-bar {
|
|
45
|
+
height: 1;
|
|
46
|
+
padding: 0 1;
|
|
47
|
+
background: $panel;
|
|
48
|
+
}
|
|
49
|
+
CaseListScreen #status-bar Static {
|
|
50
|
+
width: 1fr;
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, controller: CaseController):
|
|
55
|
+
super().__init__()
|
|
56
|
+
self._ctrl = controller
|
|
57
|
+
self._filtered_indices: list[int] = list(range(len(controller.cases)))
|
|
58
|
+
self._cur_match: int = 0
|
|
59
|
+
self._current_tag: str | None = None
|
|
60
|
+
|
|
61
|
+
def compose(self):
|
|
62
|
+
yield Header()
|
|
63
|
+
yield SearchBar(id="main-search")
|
|
64
|
+
yield CaseTable(id="main-table")
|
|
65
|
+
yield Container(
|
|
66
|
+
Horizontal(
|
|
67
|
+
Static(f"File: {self._ctrl.file_name}", id="status-file"),
|
|
68
|
+
Static(f"{self._ctrl.case_count} cases", id="status-count"),
|
|
69
|
+
Static("Tag: All", id="status-tag"),
|
|
70
|
+
Static("", id="status-dirty"),
|
|
71
|
+
id="status-inner",
|
|
72
|
+
),
|
|
73
|
+
id="status-bar",
|
|
74
|
+
)
|
|
75
|
+
yield Footer()
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
self._refresh_table()
|
|
79
|
+
self._update_status()
|
|
80
|
+
|
|
81
|
+
# -- table refresh -------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _refresh_table(self) -> None:
|
|
84
|
+
table = self.query_one("#main-table", CaseTable)
|
|
85
|
+
table.refresh_rows(self._ctrl.cases, self._filtered_indices)
|
|
86
|
+
|
|
87
|
+
def _update_status(self) -> None:
|
|
88
|
+
self.query_one("#status-count", Static).update(
|
|
89
|
+
f"{len(self._filtered_indices)}/{self._ctrl.case_count} cases"
|
|
90
|
+
)
|
|
91
|
+
tag_label = self.query_one("#status-tag", Static)
|
|
92
|
+
tag_label.update(f"Tag: {self._current_tag or 'All'}")
|
|
93
|
+
dirty = self._ctrl.dirty
|
|
94
|
+
self.query_one("#status-dirty", Static).update(
|
|
95
|
+
"[MODIFIED]" if dirty else ""
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# -- search handling -----------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def on_search_bar_search_changed(self, event: SearchBar.SearchChanged) -> None:
|
|
101
|
+
sb = self.query_one("#main-search", SearchBar)
|
|
102
|
+
self._filtered_indices = self._ctrl.search(
|
|
103
|
+
event.query, event.mode, self._current_tag
|
|
104
|
+
)
|
|
105
|
+
self._cur_match = 0
|
|
106
|
+
sb.set_match_count(len(self._filtered_indices), 0)
|
|
107
|
+
self._refresh_table()
|
|
108
|
+
self._update_status()
|
|
109
|
+
# Select first match
|
|
110
|
+
if self._filtered_indices:
|
|
111
|
+
table = self.query_one("#main-table", CaseTable)
|
|
112
|
+
table.select_row_by_index(self._filtered_indices[0])
|
|
113
|
+
self._cur_match = 1
|
|
114
|
+
sb.set_match_count(len(self._filtered_indices), 1)
|
|
115
|
+
|
|
116
|
+
def on_search_bar_search_navigated(self, event: SearchBar.SearchNavigated) -> None:
|
|
117
|
+
sb = self.query_one("#main-search", SearchBar)
|
|
118
|
+
table = self.query_one("#main-table", CaseTable)
|
|
119
|
+
if event.delta > 0:
|
|
120
|
+
table.select_next_match()
|
|
121
|
+
else:
|
|
122
|
+
table.select_prev_match()
|
|
123
|
+
# Update current match display
|
|
124
|
+
if self._filtered_indices and table.cursor_coordinate.row < len(self._filtered_indices):
|
|
125
|
+
self._cur_match = table.cursor_coordinate.row + 1
|
|
126
|
+
sb.set_match_count(len(self._filtered_indices), self._cur_match)
|
|
127
|
+
|
|
128
|
+
# -- actions -------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def action_focus_search(self) -> None:
|
|
131
|
+
sb = self.query_one("#main-search", SearchBar)
|
|
132
|
+
sb.query_one("#search-input").focus()
|
|
133
|
+
|
|
134
|
+
def action_clear_search(self) -> None:
|
|
135
|
+
self.query_one("#main-search", SearchBar).clear()
|
|
136
|
+
self._filtered_indices = list(range(len(self._ctrl.cases)))
|
|
137
|
+
self._refresh_table()
|
|
138
|
+
self._update_status()
|
|
139
|
+
|
|
140
|
+
def action_add_case(self) -> None:
|
|
141
|
+
self.app.push_screen(
|
|
142
|
+
CaseEditorScreen(),
|
|
143
|
+
callback=self._on_editor_done,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def action_edit_case(self) -> None:
|
|
147
|
+
idx = self._get_current_case_index()
|
|
148
|
+
if idx is None:
|
|
149
|
+
self.notify("No case selected", severity="warning")
|
|
150
|
+
return
|
|
151
|
+
case = self._ctrl.get_case(idx)
|
|
152
|
+
self.app.push_screen(
|
|
153
|
+
CaseEditorScreen(case),
|
|
154
|
+
callback=lambda msg: self._on_editor_done(msg, original_idx=idx),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def action_delete_case(self) -> None:
|
|
158
|
+
idx = self._get_current_case_index()
|
|
159
|
+
if idx is None:
|
|
160
|
+
self.notify("No case selected", severity="warning")
|
|
161
|
+
return
|
|
162
|
+
case_name = self._ctrl.get_case(idx).name
|
|
163
|
+
self._ctrl.delete_case(idx)
|
|
164
|
+
self.notify(f"Deleted: {case_name}")
|
|
165
|
+
self._refresh_filtered()
|
|
166
|
+
self._update_status()
|
|
167
|
+
|
|
168
|
+
def action_duplicate_case(self) -> None:
|
|
169
|
+
idx = self._get_current_case_index()
|
|
170
|
+
if idx is None:
|
|
171
|
+
self.notify("No case selected", severity="warning")
|
|
172
|
+
return
|
|
173
|
+
new_idx = self._ctrl.duplicate_case(idx)
|
|
174
|
+
self.notify(f"Duplicated as: {self._ctrl.get_case(new_idx).name}")
|
|
175
|
+
self._refresh_filtered()
|
|
176
|
+
self._update_status()
|
|
177
|
+
|
|
178
|
+
def action_run_case(self) -> None:
|
|
179
|
+
idx = self._get_current_case_index()
|
|
180
|
+
if idx is None:
|
|
181
|
+
self.notify("No case selected", severity="warning")
|
|
182
|
+
return
|
|
183
|
+
case = self._ctrl.get_case(idx)
|
|
184
|
+
self.notify(f"Running: {case.name}...")
|
|
185
|
+
try:
|
|
186
|
+
result = self._ctrl.run_case(idx)
|
|
187
|
+
status_icon = "[OK]" if result["status"] == "passed" else "[FAIL]"
|
|
188
|
+
# Build rich status line with new result fields
|
|
189
|
+
parts = [f"{status_icon} {case.name}"]
|
|
190
|
+
parts.append(f"rc={result.get('return_code')}")
|
|
191
|
+
parts.append(f"{result.get('duration', 0):.2f}s")
|
|
192
|
+
# Failure kind
|
|
193
|
+
if result.get("failure_kind"):
|
|
194
|
+
parts.append(f"kind={result['failure_kind']}")
|
|
195
|
+
# Flaky / attempts
|
|
196
|
+
if result.get("flaky"):
|
|
197
|
+
parts.append(f"flaky ({result.get('attempts', 1)} attempts)")
|
|
198
|
+
elif result.get("attempts", 1) > 1:
|
|
199
|
+
parts.append(f"{result['attempts']} attempts")
|
|
200
|
+
# Failed step (sequence)
|
|
201
|
+
if result.get("failed_step"):
|
|
202
|
+
parts.append(f"step={result['failed_step']}")
|
|
203
|
+
self.notify(
|
|
204
|
+
" | ".join(parts),
|
|
205
|
+
severity="information" if result["status"] == "passed" else "error",
|
|
206
|
+
)
|
|
207
|
+
# Show error message separately for failed cases
|
|
208
|
+
if result["status"] != "passed" and result.get("message"):
|
|
209
|
+
self.notify(f"[ERROR] {result['message']}", severity="error")
|
|
210
|
+
# Show baseline updates
|
|
211
|
+
if result.get("baseline_updated"):
|
|
212
|
+
for bu in result["baseline_updated"]:
|
|
213
|
+
self.notify(f"[BASELINE UPDATED] {bu}", severity="information")
|
|
214
|
+
except Exception as e:
|
|
215
|
+
self.notify(f"Run error: {e}", severity="error")
|
|
216
|
+
|
|
217
|
+
def action_save_file(self) -> None:
|
|
218
|
+
try:
|
|
219
|
+
self._ctrl.save()
|
|
220
|
+
self.notify(f"Saved to {self._ctrl.file_name}")
|
|
221
|
+
self._update_status()
|
|
222
|
+
except Exception as e:
|
|
223
|
+
self.notify(f"Save error: {e}", severity="error")
|
|
224
|
+
|
|
225
|
+
def action_toggle_fuzzy(self) -> None:
|
|
226
|
+
self.query_one("#main-search", SearchBar).action_toggle_fuzzy()
|
|
227
|
+
|
|
228
|
+
def action_toggle_regex(self) -> None:
|
|
229
|
+
self.query_one("#main-search", SearchBar).action_toggle_regex()
|
|
230
|
+
|
|
231
|
+
def action_toggle_substring(self) -> None:
|
|
232
|
+
self.query_one("#main-search", SearchBar).action_toggle_substring()
|
|
233
|
+
|
|
234
|
+
# -- helpers -------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _get_current_case_index(self) -> int | None:
|
|
237
|
+
table = self.query_one("#main-table", CaseTable)
|
|
238
|
+
return table.get_selected_index()
|
|
239
|
+
|
|
240
|
+
def _refresh_filtered(self) -> None:
|
|
241
|
+
sb = self.query_one("#main-search", SearchBar)
|
|
242
|
+
self._filtered_indices = self._ctrl.search(
|
|
243
|
+
sb.query, sb.mode, self._current_tag
|
|
244
|
+
)
|
|
245
|
+
self._refresh_table()
|
|
246
|
+
self._update_status()
|
|
247
|
+
|
|
248
|
+
def _on_editor_done(self, msg, original_idx=None) -> None:
|
|
249
|
+
if isinstance(msg, CaseEditorScreen.Saved):
|
|
250
|
+
if original_idx is not None:
|
|
251
|
+
self._ctrl.update_case(original_idx, msg.case)
|
|
252
|
+
else:
|
|
253
|
+
self._ctrl.add_case(msg.case)
|
|
254
|
+
self._refresh_filtered()
|
|
255
|
+
self._update_status()
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""DataTable wrapper for displaying test cases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.widgets import DataTable
|
|
6
|
+
from textual.coordinate import Coordinate
|
|
7
|
+
|
|
8
|
+
from ...core.test_case import TestCase
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CaseTable(DataTable):
|
|
12
|
+
"""DataTable pre-configured for test case display."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
CaseTable {
|
|
16
|
+
height: 1fr;
|
|
17
|
+
min-height: 5;
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
COLUMNS = ("#", "Name", "Command", "Tags", "Timeout", "Mode")
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args, **kwargs):
|
|
24
|
+
super().__init__(cursor_type="row", zebra_stripes=True, *args, **kwargs)
|
|
25
|
+
self._case_indices: list[int] = []
|
|
26
|
+
self._all_tags: list[str] = []
|
|
27
|
+
self._filtered_tag: str | None = None
|
|
28
|
+
|
|
29
|
+
def on_mount(self) -> None:
|
|
30
|
+
for col in self.COLUMNS:
|
|
31
|
+
self.add_column(col, key=col.lower())
|
|
32
|
+
|
|
33
|
+
# -- public API ----------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def refresh_rows(self, cases: list[TestCase], indices: list[int]) -> None:
|
|
36
|
+
"""Clear table and repopulate with the cases at *indices*."""
|
|
37
|
+
self.clear()
|
|
38
|
+
self._case_indices = list(indices)
|
|
39
|
+
for i, ci in enumerate(indices):
|
|
40
|
+
tc = cases[ci]
|
|
41
|
+
mode = "seq" if tc.steps else "cmd"
|
|
42
|
+
tags = ",".join(tc.tags) if tc.tags else "-"
|
|
43
|
+
timeout = str(tc.timeout) if tc.timeout else "-"
|
|
44
|
+
self.add_row(
|
|
45
|
+
str(i + 1),
|
|
46
|
+
tc.name,
|
|
47
|
+
self._truncate(tc.command, 30),
|
|
48
|
+
tags,
|
|
49
|
+
timeout,
|
|
50
|
+
mode,
|
|
51
|
+
key=str(ci),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def get_selected_index(self) -> int | None:
|
|
55
|
+
"""Return the case index of the currently selected row."""
|
|
56
|
+
if self.row_count == 0:
|
|
57
|
+
return None
|
|
58
|
+
row_key = self.coordinate_to_cell_key(self.cursor_coordinate)
|
|
59
|
+
if row_key is not None:
|
|
60
|
+
return int(str(row_key.row_key.value))
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def select_row_by_index(self, case_index: int) -> None:
|
|
64
|
+
"""Move cursor to the row corresponding to *case_index*."""
|
|
65
|
+
try:
|
|
66
|
+
row_key = self._row_key_for_case(case_index)
|
|
67
|
+
if row_key is not None:
|
|
68
|
+
self.move_cursor(row=row_key)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def select_next_match(self) -> int | None:
|
|
73
|
+
"""Move down one row; return new selected case index."""
|
|
74
|
+
if self.row_count == 0:
|
|
75
|
+
return None
|
|
76
|
+
cr = self.cursor_coordinate
|
|
77
|
+
new_row = min(cr.row + 1, self.row_count - 1)
|
|
78
|
+
self.move_cursor(row=new_row)
|
|
79
|
+
return self.get_selected_index()
|
|
80
|
+
|
|
81
|
+
def select_prev_match(self) -> int | None:
|
|
82
|
+
"""Move up one row; return new selected case index."""
|
|
83
|
+
if self.row_count == 0:
|
|
84
|
+
return None
|
|
85
|
+
cr = self.cursor_coordinate
|
|
86
|
+
new_row = max(cr.row - 1, 0)
|
|
87
|
+
self.move_cursor(row=new_row)
|
|
88
|
+
return self.get_selected_index()
|
|
89
|
+
|
|
90
|
+
# -- tag filter ----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def set_all_tags(self, tags: list[str]) -> None:
|
|
93
|
+
self._all_tags = list(tags)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def filtered_tag(self) -> str | None:
|
|
97
|
+
return self._filtered_tag
|
|
98
|
+
|
|
99
|
+
# -- helpers -------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _truncate(text: str, max_len: int) -> str:
|
|
103
|
+
if len(text) <= max_len:
|
|
104
|
+
return text
|
|
105
|
+
return text[: max_len - 2] + ".."
|
|
106
|
+
|
|
107
|
+
def _row_key_for_case(self, case_index: int) -> object | None:
|
|
108
|
+
"""Find the row key for a given *case_index*."""
|
|
109
|
+
for i in range(self.row_count):
|
|
110
|
+
key = self.coordinate_to_cell_key(Coordinate(i, 0))
|
|
111
|
+
if key is not None and str(key.row_key.value) == str(case_index):
|
|
112
|
+
return key.row_key
|
|
113
|
+
return None
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Widget for editing a test case's ``expected`` dict.
|
|
2
|
+
|
|
3
|
+
Provides structured inputs for known keys (return_code, output_contains,
|
|
4
|
+
output_matches, compare_files) and a free-form area for custom key-value pairs.
|
|
5
|
+
|
|
6
|
+
Uses an internal ``_data`` dict so that ``load()`` / ``to_dict()`` work
|
|
7
|
+
even when the widget is not mounted (e.g. in tests).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Dict
|
|
15
|
+
|
|
16
|
+
from textual.widgets import Input, TextArea, Static
|
|
17
|
+
from textual.containers import Vertical
|
|
18
|
+
from textual.message import Message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExpectedEditor(Vertical):
|
|
22
|
+
"""Nested-dict editor for the ``expected`` field of a test case."""
|
|
23
|
+
|
|
24
|
+
DEFAULT_CSS = """
|
|
25
|
+
ExpectedEditor {
|
|
26
|
+
height: auto;
|
|
27
|
+
border: solid $primary;
|
|
28
|
+
padding: 1;
|
|
29
|
+
}
|
|
30
|
+
ExpectedEditor Static.label {
|
|
31
|
+
color: $text-muted;
|
|
32
|
+
height: 1;
|
|
33
|
+
margin-top: 1;
|
|
34
|
+
}
|
|
35
|
+
ExpectedEditor #expected-return-code {
|
|
36
|
+
width: 10;
|
|
37
|
+
}
|
|
38
|
+
ExpectedEditor #expected-output-contains {
|
|
39
|
+
height: 3;
|
|
40
|
+
}
|
|
41
|
+
ExpectedEditor #expected-custom {
|
|
42
|
+
height: 4;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
class Changed(Message):
|
|
47
|
+
"""Emitted whenever the expected dict is modified."""
|
|
48
|
+
|
|
49
|
+
_KNOWN_KEYS = {"return_code", "output_contains", "output_matches", "compare_files"}
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args, **kwargs):
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self._data: Dict[str, Any] = {}
|
|
54
|
+
|
|
55
|
+
def compose(self):
|
|
56
|
+
yield Static("─ Expected ─", classes="label")
|
|
57
|
+
yield Static("return_code:", classes="label")
|
|
58
|
+
yield Input(placeholder="0", id="expected-return-code")
|
|
59
|
+
yield Static("output_contains (one per line):", classes="label")
|
|
60
|
+
yield TextArea("", id="expected-output-contains")
|
|
61
|
+
yield Static("output_matches (regex):", classes="label")
|
|
62
|
+
yield Input(placeholder=".*", id="expected-output-matches")
|
|
63
|
+
yield Static(
|
|
64
|
+
"compare_files (one JSON per line: {actual, baseline, type, start_line?, end_line?, start_column?, end_column?}):",
|
|
65
|
+
classes="label",
|
|
66
|
+
)
|
|
67
|
+
yield TextArea("", id="expected-compare-files")
|
|
68
|
+
yield Static("Custom key-value pairs (key=value, one per line):", classes="label")
|
|
69
|
+
yield TextArea("", id="expected-custom")
|
|
70
|
+
|
|
71
|
+
def on_mount(self) -> None:
|
|
72
|
+
"""Populate UI from internal data on mount."""
|
|
73
|
+
self._sync_to_ui()
|
|
74
|
+
|
|
75
|
+
# -- public API ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def load(self, expected: Dict[str, Any]) -> None:
|
|
78
|
+
"""Populate the editor from an *expected* dict."""
|
|
79
|
+
self._data = copy.deepcopy(expected)
|
|
80
|
+
if self.is_mounted:
|
|
81
|
+
self._sync_to_ui()
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
"""Reconstruct the expected dict from the current widget values."""
|
|
85
|
+
if self.is_mounted:
|
|
86
|
+
self._sync_from_ui()
|
|
87
|
+
return copy.deepcopy(self._data)
|
|
88
|
+
|
|
89
|
+
# -- internal sync -------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _sync_to_ui(self) -> None:
|
|
92
|
+
"""Push internal ``_data`` into the widget children."""
|
|
93
|
+
rc = self._data.get("return_code")
|
|
94
|
+
self.query_one("#expected-return-code", Input).value = str(rc) if rc is not None else ""
|
|
95
|
+
|
|
96
|
+
oc = self._data.get("output_contains", [])
|
|
97
|
+
self.query_one("#expected-output-contains", TextArea).text = "\n".join(oc)
|
|
98
|
+
|
|
99
|
+
om = self._data.get("output_matches", "")
|
|
100
|
+
self.query_one("#expected-output-matches", Input).value = om
|
|
101
|
+
|
|
102
|
+
cf = self._data.get("compare_files", [])
|
|
103
|
+
cf_lines = [json.dumps(item, ensure_ascii=False) for item in cf]
|
|
104
|
+
self.query_one("#expected-compare-files", TextArea).text = "\n".join(cf_lines)
|
|
105
|
+
|
|
106
|
+
custom_lines = [
|
|
107
|
+
f"{k}={json.dumps(v, ensure_ascii=False)}"
|
|
108
|
+
for k, v in self._data.items()
|
|
109
|
+
if k not in self._KNOWN_KEYS
|
|
110
|
+
]
|
|
111
|
+
self.query_one("#expected-custom", TextArea).text = "\n".join(custom_lines)
|
|
112
|
+
|
|
113
|
+
def _sync_from_ui(self) -> None:
|
|
114
|
+
"""Pull widget children values into ``_data``."""
|
|
115
|
+
self._data = {}
|
|
116
|
+
|
|
117
|
+
rc_text = self.query_one("#expected-return-code", Input).value.strip()
|
|
118
|
+
if rc_text:
|
|
119
|
+
try:
|
|
120
|
+
self._data["return_code"] = int(rc_text)
|
|
121
|
+
except ValueError:
|
|
122
|
+
self._data["return_code"] = rc_text
|
|
123
|
+
|
|
124
|
+
oc_text = self.query_one("#expected-output-contains", TextArea).text.strip()
|
|
125
|
+
if oc_text:
|
|
126
|
+
self._data["output_contains"] = [
|
|
127
|
+
line for line in oc_text.splitlines() if line.strip()
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
om_text = self.query_one("#expected-output-matches", Input).value.strip()
|
|
131
|
+
if om_text:
|
|
132
|
+
self._data["output_matches"] = om_text
|
|
133
|
+
|
|
134
|
+
cf_text = self.query_one("#expected-compare-files", TextArea).text.strip()
|
|
135
|
+
if cf_text:
|
|
136
|
+
cf_list = []
|
|
137
|
+
for line in cf_text.splitlines():
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
cf_list.append(json.loads(line))
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
pass
|
|
145
|
+
if cf_list:
|
|
146
|
+
self._data["compare_files"] = cf_list
|
|
147
|
+
|
|
148
|
+
custom_text = self.query_one("#expected-custom", TextArea).text.strip()
|
|
149
|
+
if custom_text:
|
|
150
|
+
for line in custom_text.splitlines():
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if "=" not in line:
|
|
153
|
+
continue
|
|
154
|
+
key, _, value = line.partition("=")
|
|
155
|
+
key = key.strip()
|
|
156
|
+
try:
|
|
157
|
+
self._data[key] = json.loads(value.strip())
|
|
158
|
+
except (json.JSONDecodeError, ValueError):
|
|
159
|
+
self._data[key] = value.strip()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Search bar widget with multi-mode search (substring / fuzzy / regex).
|
|
2
|
+
|
|
3
|
+
Emits :class:`SearchChanged` when the query text or mode changes, and
|
|
4
|
+
:class:`SearchNavigated` when the user cycles through matches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.widgets import Input, Static
|
|
11
|
+
from textual.containers import Horizontal
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SearchBar(Horizontal):
|
|
16
|
+
"""A search bar with mode toggle and result count."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
SearchBar {
|
|
20
|
+
height: auto;
|
|
21
|
+
padding: 0 1;
|
|
22
|
+
margin-bottom: 1;
|
|
23
|
+
}
|
|
24
|
+
SearchBar #search-input {
|
|
25
|
+
width: 40;
|
|
26
|
+
}
|
|
27
|
+
SearchBar #search-mode {
|
|
28
|
+
width: 12;
|
|
29
|
+
padding: 0 1;
|
|
30
|
+
color: $text-muted;
|
|
31
|
+
}
|
|
32
|
+
SearchBar #search-count {
|
|
33
|
+
width: auto;
|
|
34
|
+
padding: 0 1;
|
|
35
|
+
color: $text-muted;
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Maps keyboard modifiers to modes
|
|
40
|
+
_MODES = {
|
|
41
|
+
0: "substring",
|
|
42
|
+
2: "regex", # Alt pressed
|
|
43
|
+
4: "fuzzy", # Ctrl pressed → fuzzy mode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class SearchChanged(Message):
|
|
47
|
+
"""Emitted when search query or mode changes."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, query: str, mode: str):
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.query = query
|
|
52
|
+
self.mode = mode
|
|
53
|
+
|
|
54
|
+
class SearchNavigated(Message):
|
|
55
|
+
"""Emitted to jump to match N."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, delta: int):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.delta = delta
|
|
60
|
+
|
|
61
|
+
def __init__(self, *args, **kwargs):
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
self._query = ""
|
|
64
|
+
self._mode: str = "substring"
|
|
65
|
+
self._match_count = 0
|
|
66
|
+
self._history: list[str] = []
|
|
67
|
+
self._history_idx = -1
|
|
68
|
+
|
|
69
|
+
def compose(self):
|
|
70
|
+
yield Input(placeholder="Search...", id="search-input")
|
|
71
|
+
yield Static("[子串]", id="search-mode")
|
|
72
|
+
yield Static("", id="search-count")
|
|
73
|
+
|
|
74
|
+
# -- mode toggle via key bindings ----------------------------------------
|
|
75
|
+
|
|
76
|
+
def _handle_mode_toggle(self, mode: str, label: str) -> None:
|
|
77
|
+
if self._mode != mode:
|
|
78
|
+
self._mode = mode
|
|
79
|
+
self.query_one("#search-mode", Static).update(label)
|
|
80
|
+
self.post_message(self.SearchChanged(self._query, self._mode))
|
|
81
|
+
|
|
82
|
+
def action_toggle_substring(self) -> None:
|
|
83
|
+
self._handle_mode_toggle("substring", "[子串]")
|
|
84
|
+
|
|
85
|
+
def action_toggle_fuzzy(self) -> None:
|
|
86
|
+
self._handle_mode_toggle("fuzzy", "[模糊]")
|
|
87
|
+
|
|
88
|
+
def action_toggle_regex(self) -> None:
|
|
89
|
+
self._handle_mode_toggle("regex", "[正则]")
|
|
90
|
+
|
|
91
|
+
# -- input handling ------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@on(Input.Changed, "#search-input")
|
|
94
|
+
def _on_input_changed(self, event: Input.Changed) -> None:
|
|
95
|
+
self._query = event.value.strip()
|
|
96
|
+
self.post_message(self.SearchChanged(self._query, self._mode))
|
|
97
|
+
|
|
98
|
+
@on(Input.Submitted, "#search-input")
|
|
99
|
+
def _on_input_submitted(self, _event: Input.Submitted) -> None:
|
|
100
|
+
# Add to history (dedup, keep last 10)
|
|
101
|
+
if self._query and (not self._history or self._history[-1] != self._query):
|
|
102
|
+
self._history.append(self._query)
|
|
103
|
+
if len(self._history) > 10:
|
|
104
|
+
self._history.pop(0)
|
|
105
|
+
self._history_idx = -1
|
|
106
|
+
self.post_message(self.SearchNavigated(1))
|
|
107
|
+
|
|
108
|
+
# -- public API ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def set_match_count(self, total: int, current: int = 0) -> None:
|
|
111
|
+
"""Update the match counter display."""
|
|
112
|
+
self._match_count = total
|
|
113
|
+
label = f"{current}/{total}" if total > 0 else ""
|
|
114
|
+
self.query_one("#search-count", Static).update(label)
|
|
115
|
+
|
|
116
|
+
def clear(self) -> None:
|
|
117
|
+
"""Reset search bar."""
|
|
118
|
+
inp = self.query_one("#search-input", Input)
|
|
119
|
+
inp.clear()
|
|
120
|
+
self._query = ""
|
|
121
|
+
self._match_count = 0
|
|
122
|
+
self.query_one("#search-count", Static).update("")
|
|
123
|
+
|
|
124
|
+
def history_previous(self) -> None:
|
|
125
|
+
"""Cycle to previous history entry."""
|
|
126
|
+
if not self._history:
|
|
127
|
+
return
|
|
128
|
+
if self._history_idx == -1:
|
|
129
|
+
self._history_idx = len(self._history) - 1
|
|
130
|
+
elif self._history_idx > 0:
|
|
131
|
+
self._history_idx -= 1
|
|
132
|
+
text = self._history[self._history_idx]
|
|
133
|
+
inp = self.query_one("#search-input", Input)
|
|
134
|
+
inp.value = text
|
|
135
|
+
self._query = text
|
|
136
|
+
self.post_message(self.SearchChanged(self._query, self._mode))
|
|
137
|
+
|
|
138
|
+
def history_next(self) -> None:
|
|
139
|
+
if not self._history or self._history_idx == -1:
|
|
140
|
+
return
|
|
141
|
+
self._history_idx += 1
|
|
142
|
+
if self._history_idx >= len(self._history):
|
|
143
|
+
self._history_idx = -1
|
|
144
|
+
inp = self.query_one("#search-input", Input)
|
|
145
|
+
inp.value = ""
|
|
146
|
+
self._query = ""
|
|
147
|
+
else:
|
|
148
|
+
text = self._history[self._history_idx]
|
|
149
|
+
inp = self.query_one("#search-input", Input)
|
|
150
|
+
inp.value = text
|
|
151
|
+
self._query = text
|
|
152
|
+
self.post_message(self.SearchChanged(self._query, self._mode))
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def query(self) -> str:
|
|
156
|
+
return self._query
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def mode(self) -> str:
|
|
160
|
+
return self._mode
|