dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,593 @@
1
+ """The Test page's rail (the owner's ruling, 2026-09-25; DESIGN.md "The
2
+ Variables drawer"): 40 px of labelled buttons down the left edge, and ONE
3
+ drawer at a time beside them, 320 px, which pushes the plots narrower
4
+ rather than covering them (`behavior=desktop`: below Quasar's 1024 px
5
+ breakpoint a drawer otherwise turns into an overlay). Plotly follows the
6
+ narrower slot by itself — NiceGUI's plotly element observes its size. Which
7
+ drawer is open is the tab's (`TabState.drawer`), so a switch of test keeps
8
+ it open."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from functools import partial
14
+ from typing import TYPE_CHECKING, Any
15
+ from urllib.parse import quote
16
+
17
+ import regex
18
+ from nicegui import ui
19
+
20
+ from ...reporting.status import (
21
+ FAIL,
22
+ NO_REF,
23
+ NOT_RUN,
24
+ PASS,
25
+ SCORING_FAILED,
26
+ SIM_FAIL,
27
+ STATUS_CLASSES,
28
+ classify,
29
+ )
30
+ from ..needle import matcher
31
+ from .drawer_controls import TIP_ABOVE, filter_box, menu_button
32
+ from .test_list import Row, TestList, _name, _package
33
+ from .variables_drawer import VariablesDrawer
34
+
35
+ if TYPE_CHECKING:
36
+ from .test_page import TestPage
37
+
38
+ RAIL = 40
39
+ #: A dragged panel's bounds (K5): at least ``MIN_PANEL`` px, at most
40
+ #: ``MAX_FRACTION`` of the window AND the window less the rail and
41
+ #: ``MIN_PLOTS`` px for the page's plots column (at 900 px, 60% left the
42
+ #: plots 320 px and a context chip's × past its card). The browser clamps
43
+ #: to both, which only it can measure, and sends the window's width with
44
+ #: the release; the server holds the same bounds again (`max_panel`).
45
+ MIN_PANEL, MAX_FRACTION, MIN_PLOTS = 240, 0.6, 480
46
+
47
+
48
+ def max_panel(window: float) -> float:
49
+ """The widest a dragged panel may be in a ``window`` px wide window —
50
+ `_WIDTH`'s ``max``. The floor wins when a narrow window leaves less."""
51
+ return min(MAX_FRACTION * window, window - RAIL - MIN_PLOTS)
52
+
53
+
54
+ #: The drawer's width in the BROWSER (K5), installed by `Rail.__init__` as
55
+ #: `window.__dstfDrawer`: the visible panel (`offsetParent`: both drawers'
56
+ #: panels are in the row, one hidden, B10), the clamp to `MIN_PANEL` and
57
+ #: `max_panel` — only the browser knows the window's width — and `apply`,
58
+ #: which sets the panel's width and the drawer's `width` prop on NiceGUI's
59
+ #: element store (`mounted_app.elements`, reactive, so Quasar re-lays the
60
+ #: page out and Plotly's observer reflows each plot) while the pointer or a
61
+ #: key moves it. `mounted_app.elements[id].props` is NiceGUI 3.16's
62
+ #: INTERNAL client-side store, not an API: the lookup is guarded (`store`),
63
+ #: so on a NiceGUI that renames it the panel still moves and the release
64
+ #: still sends the width — only the drawer lags the panel until the
65
+ #: server's update (the Playwright test reading the drawer's width
66
+ #: mid-drag fails first). `fit` clamps a width the window no longer holds
67
+ #: — the tab's width from a wider window — on load (the width the server
68
+ #: has, sent at once) and on a window `resize` (sent once, 150 ms after
69
+ #: the last).
70
+ _WIDTH = """(() => {
71
+ const RAIL = __RAIL__, MIN = __MIN__;
72
+ const panel = () => [...document.querySelectorAll('.dstf-drawer-panel')]
73
+ .find((p) => p.offsetParent !== null);
74
+ const max = () => Math.min(__MAX__ * window.innerWidth, window.innerWidth - RAIL - __PLOTS__);
75
+ const clamp = (w) => Math.round(Math.max(MIN, Math.min(w, max())));
76
+ const width = (p) => Math.round(p.getBoundingClientRect().width);
77
+ // `mounted_app` is a top-level `let` of nicegui.js — a global binding,
78
+ // no property of `window` — hence the try.
79
+ const store = () => {
80
+ try { return mounted_app?.elements; } catch { return undefined; }
81
+ };
82
+ const bounds = () => document.getElementById('__GRIP__')
83
+ ?.setAttribute('aria-valuemax', Math.round(Math.max(MIN, max())));
84
+ const apply = (p, w) => {
85
+ p.style.width = w + 'px';
86
+ const drawer = store()?.[__DRAWER__];
87
+ if (drawer) drawer.props.width = RAIL + w;
88
+ document.getElementById('__GRIP__')?.setAttribute('aria-valuenow', w);
89
+ bounds();
90
+ };
91
+ const send = (w) => emitEvent('dstf_drawer_width', w, window.innerWidth);
92
+ let timer = null;
93
+ const fit = (known) => {
94
+ const p = panel();
95
+ const w = known ?? (p ? width(p) : null);
96
+ bounds();
97
+ if (w === null) return;
98
+ const c = clamp(w);
99
+ if (c === w) return;
100
+ if (p) apply(p, c);
101
+ clearTimeout(timer);
102
+ if (known !== undefined) send(c);
103
+ else timer = setTimeout(() => send(c), 150);
104
+ };
105
+ window.__dstfDrawer = {panel, max, clamp, width, apply, fit};
106
+ if (!window.__dstfDrawerResize) {
107
+ window.__dstfDrawerResize = true;
108
+ window.addEventListener('resize', () => window.__dstfDrawer?.fit());
109
+ }
110
+ })();"""
111
+
112
+ #: The grip's drag: the panel follows the pointer (`apply`), and the
113
+ #: release sends the width to Python once, which sets the same values and
114
+ #: keeps them in `TabState`.
115
+ _DRAG = """(e) => {
116
+ if (e.button !== 0) return;
117
+ const d = window.__dstfDrawer, panel = d?.panel();
118
+ if (!panel) return;
119
+ e.preventDefault();
120
+ const handle = e.currentTarget;
121
+ const x0 = e.clientX, w0 = panel.getBoundingClientRect().width;
122
+ let w = w0;
123
+ handle.setPointerCapture(e.pointerId);
124
+ const move = (m) => {
125
+ w = d.clamp(w0 + m.clientX - x0);
126
+ d.apply(panel, w);
127
+ };
128
+ const up = () => {
129
+ handle.removeEventListener('pointermove', move);
130
+ handle.removeEventListener('pointerup', up);
131
+ emit(w, window.innerWidth);
132
+ };
133
+ handle.addEventListener('pointermove', move);
134
+ handle.addEventListener('pointerup', up);
135
+ }"""
136
+
137
+ #: The grip from the keyboard (a focusable `separator`, WAI-ARIA's window
138
+ #: splitter): ←/→ 16 px, with Shift 64, Home the narrowest, End the
139
+ #: widest; the panel follows each key, and the key's release sends the
140
+ #: width once — a held → sends one width, not one per repeat. A key moves
141
+ #: the grip's `dirty` mark and a send clears it; leaving the grip sends a
142
+ #: width still marked: a key held while the focus moves (→ held, Tab) is
143
+ #: released on another element, and the width it left never reached the
144
+ #: server.
145
+ _STEP, _BIG_STEP = 16, 64
146
+ _GRIP_KEYS = ("ArrowLeft", "ArrowRight", "Home", "End")
147
+ _KEYDOWN = """(e) => {
148
+ const d = window.__dstfDrawer, panel = d?.panel();
149
+ if (!panel) return;
150
+ const w = d.width(panel), step = e.shiftKey ? __BIG__ : __STEP__;
151
+ const to = {ArrowLeft: w - step, ArrowRight: w + step, Home: __MIN__, End: d.max()}[e.key];
152
+ if (to === undefined) return;
153
+ e.preventDefault();
154
+ d.apply(panel, d.clamp(to));
155
+ e.currentTarget.dataset.dirty = '1';
156
+ }"""
157
+ _KEYUP = """(e) => {
158
+ if (!__KEYS__.includes(e.key)) return;
159
+ const d = window.__dstfDrawer, panel = d?.panel();
160
+ delete e.currentTarget.dataset.dirty;
161
+ if (panel) emit(d.width(panel), window.innerWidth);
162
+ }"""
163
+ _BLUR = """(e) => {
164
+ if (!e.currentTarget.dataset.dirty) return;
165
+ delete e.currentTarget.dataset.dirty;
166
+ const d = window.__dstfDrawer, panel = d?.panel();
167
+ if (panel) emit(d.width(panel), window.innerWidth);
168
+ }"""
169
+
170
+
171
+ EDITED = "EDITED"
172
+ #: The Tests drawer's status pills, in the Suite's order, and EDITED.
173
+ PILLS = (PASS, FAIL, SIM_FAIL, NO_REF, NOT_RUN, EDITED)
174
+ #: Each pill's `.dstf-pill-<status>` tokens; EDITED reads as the Suite's
175
+ #: Edits cell and the rows' ``N unsaved`` pill do.
176
+ PILL_CSS = {**{s: STATUS_CLASSES[s] for s in PILLS if s != EDITED}, EDITED: "warn"}
177
+ #: "Status, failures first": FAIL, SIM_FAIL, scoring failed, NO_REF, PASS,
178
+ #: NOT RUN (no result, D145), then no verdict yet — NOT `STATUS_ORDER`,
179
+ #: which lists PASS first.
180
+ FAILURES_FIRST = (FAIL, SIM_FAIL, SCORING_FAILED, NO_REF, PASS, NOT_RUN, "")
181
+ SORTS = (
182
+ ("package", "Package order"),
183
+ ("name", "Name A→Z"),
184
+ ("status", "Status, failures first"),
185
+ )
186
+ NAMES = (("short", "Short"), ("full", "Full"))
187
+
188
+
189
+ class TestsDrawer:
190
+ """The Suite page's list, without checkboxes, and the drawers' one
191
+ control set (the owner's ruling Q7): the filter with `.*` / `Aa`, sort,
192
+ group and names, and compact status pills with EDITED — every control
193
+ the tab's (`TabState`), so a switch of test keeps them. This test is
194
+ highlighted; a click opens that test in this tab, and pending edits are
195
+ the Session's and survive the switch."""
196
+
197
+ def __init__(self, page: TestPage, panel: ui.column) -> None:
198
+ self.page = page
199
+ state = page.state
200
+ #: The filter's kept ids and its sentence, and what they were
201
+ #: computed from (`_kept`).
202
+ self.kept: tuple[object, set[str], str | None] | None = None
203
+ with panel:
204
+ ui.label("Tests").classes("text-subtitle2")
205
+ self.box = filter_box(
206
+ state.tests_needle,
207
+ state.tests_regex,
208
+ state.tests_case,
209
+ self._on_filter,
210
+ "filter tests",
211
+ "tests-drawer:",
212
+ )
213
+ with ui.row().classes("items-center no-wrap gap-0 w-full"):
214
+ self.sort = menu_button(
215
+ "sort",
216
+ "Sort",
217
+ SORTS,
218
+ state.tests_sort,
219
+ self._on_sort,
220
+ "tests-drawer:sort",
221
+ )
222
+ self.group = (
223
+ ui.button(icon="account_tree", on_click=self._on_group)
224
+ .props("flat dense round")
225
+ .mark("tests-drawer:group")
226
+ )
227
+ self.group.props["aria-label"] = "Group by package"
228
+ with self.group:
229
+ self.group_tip = ui.tooltip("").props(TIP_ABOVE)
230
+ self.names = menu_button(
231
+ "label",
232
+ "Names",
233
+ NAMES,
234
+ "short" if state.tests_short else "full",
235
+ self._on_names,
236
+ "tests-drawer:names",
237
+ )
238
+ with ui.row().classes("items-center gap-1 w-full"):
239
+ self.pills: dict[str, ui.button] = {}
240
+ for word in PILLS:
241
+ self.pills[word] = (
242
+ ui.button(on_click=partial(self._on_pill, word))
243
+ .props("flat dense no-caps")
244
+ .classes(
245
+ f"dstf-count-pill dstf-pill dstf-pill-{PILL_CSS[word]}"
246
+ )
247
+ # One word: NiceGUI splits a marker on whitespace,
248
+ # and "NOT RUN" has a space.
249
+ .mark(f"tests-drawer:pill:{word.replace(' ', '_')}")
250
+ )
251
+ self.list = TestList(
252
+ page.shell,
253
+ on_open=self._open,
254
+ current=page.model_id,
255
+ marker="tests-drawer:",
256
+ )
257
+ self._paint_group()
258
+ self._rebuild()
259
+
260
+ # -- the controls ------------------------------------------------------------
261
+
262
+ def _on_filter(self, needle: str, regex: bool, case: bool) -> None:
263
+ state = self.page.state
264
+ state.tests_needle, state.tests_regex, state.tests_case = needle, regex, case
265
+ self._filter()
266
+
267
+ def _on_sort(self, value: str) -> None:
268
+ self.page.state.tests_sort = value
269
+ self._rebuild()
270
+
271
+ def _on_group(self) -> None:
272
+ state = self.page.state
273
+ state.tests_grouped = not state.tests_grouped
274
+ self._paint_group()
275
+ self._rebuild()
276
+
277
+ def _paint_group(self) -> None:
278
+ """Pressed while grouped; the tooltip says what a click does."""
279
+ grouped = self.page.state.tests_grouped
280
+ self.group.props(f'aria-pressed="{str(grouped).lower()}"')
281
+ self.group_tip.set_text("Show flat" if grouped else "Group by package")
282
+
283
+ def _on_names(self, value: str) -> None:
284
+ self.page.state.tests_short = value == "short"
285
+ self._rebuild()
286
+
287
+ def _on_pill(self, word: str) -> None:
288
+ self.page.state.tests_pills.symmetric_difference_update({word})
289
+ self._filter()
290
+
291
+ # -- the rows ----------------------------------------------------------------
292
+
293
+ def _ids(self) -> list[str]:
294
+ """The tests in the chosen order; package order is the shell's."""
295
+ ids = self.page.shell.visible_tests()
296
+ state = self.page.state
297
+ if state.tests_sort == "name":
298
+ return sorted(
299
+ ids, key=lambda m: (_name(m, state.tests_short).casefold(), m)
300
+ )
301
+ if state.tests_sort == "status":
302
+ position = {m: i for i, m in enumerate(ids)}
303
+ return sorted(ids, key=lambda m: (_rank(self._verdict(m)), position[m]))
304
+ return ids
305
+
306
+ def _verdict(self, model_id: str) -> str:
307
+ """What the row's badge says (`TestList.paint`), read before the
308
+ rows exist: the order is decided first."""
309
+ comp = self.page.shell.session.comparisons.get(model_id)
310
+ if comp is not None:
311
+ return classify(comp)
312
+ return SCORING_FAILED if model_id in self.page.shell.load.failed_models else ""
313
+
314
+ def _drawn(self) -> list[str]:
315
+ """The order the rows are drawn in now: `_ids`, by package first
316
+ when grouped (`TestList.rebuild`)."""
317
+ ids = self._ids()
318
+ if not self.page.state.tests_grouped:
319
+ return ids
320
+ position = {m: i for i, m in enumerate(ids)}
321
+ return sorted(ids, key=lambda m: (_package(m), position[m]))
322
+
323
+ def _rebuild(self) -> None:
324
+ """The rows from scratch in the chosen order and grouping, and the
325
+ tab's filter APPLIED to them: a drawer built on the next test's page
326
+ showed the filter in its box over every row until some event
327
+ happened to apply it."""
328
+ state = self.page.state
329
+ self.list.rebuild(
330
+ self._ids(), grouped=state.tests_grouped, short=state.tests_short
331
+ )
332
+ self._filter()
333
+
334
+ def _open(self, model_id: str) -> None:
335
+ ui.navigate.to(f"/test/{quote(model_id, safe='')}")
336
+
337
+ def _kept(self) -> tuple[set[str], str | None]:
338
+ """The ids the filter keeps, and the sentence under the box — every
339
+ id, and why, for a bad pattern or one that ran out of time: the
340
+ drawer hides nothing then. Cached on the needle, its toggles and
341
+ the SET of ids, never their order: events repaint the rows as a
342
+ queue's tests end, a rescore under the status sort reorders them,
343
+ and a pathological pattern cost 0.79 s of CPU per pass over 331 ids
344
+ (the plan's executing review)."""
345
+ state = self.page.state
346
+ ids = frozenset(self.list.rows)
347
+ key = (state.tests_needle, state.tests_regex, state.tests_case, ids)
348
+ if self.kept is None or self.kept[0] != key:
349
+ why: str | None = None
350
+ try:
351
+ match = matcher(
352
+ state.tests_needle, regex=state.tests_regex, case=state.tests_case
353
+ )
354
+ kept = {m for m in ids if match(m)}
355
+ except regex.error as e:
356
+ kept, why = set(ids), f"not a regular expression: {e}"
357
+ except TimeoutError:
358
+ kept, why = set(ids), "the pattern took too long; simplify it"
359
+ self.kept = (key, kept, why)
360
+ return self.kept[1], self.kept[2]
361
+
362
+ def _filter(self) -> None:
363
+ """The filter and the pills over the rows; the pills count every
364
+ row, whatever the filter. The statuses pressed are OR'd, and EDITED
365
+ with them — the Suite's rule (`suite_grid.js`). A test the open
366
+ could not score is SIM_FAIL to the pills, counted and kept, as the
367
+ Suite's row says it (`suite_rows`); the status sort keeps it a
368
+ place of its own (`FAILURES_FIRST`)."""
369
+ state = self.page.state
370
+ counts = dict.fromkeys(PILLS, 0)
371
+ for row in self.list.rows.values():
372
+ pill = _pill_word(row)
373
+ if pill in counts:
374
+ counts[pill] += 1
375
+ if row.pending:
376
+ counts[EDITED] += 1
377
+ for word, button in self.pills.items():
378
+ on = word in state.tests_pills
379
+ button.set_text(f"{word} {counts[word]}")
380
+ button.props(f'aria-pressed="{str(on).lower()}"')
381
+ if on:
382
+ button.classes(add="dstf-count-pill-on")
383
+ else:
384
+ button.classes(remove="dstf-count-pill-on")
385
+ # One switched on stays enabled at 0, so it can be switched off.
386
+ button.set_enabled(counts[word] > 0 or on)
387
+ kept, why = self._kept()
388
+ self.box.say(why)
389
+ pills = state.tests_pills
390
+
391
+ def keep(row: Row) -> bool:
392
+ if row.model_id not in kept:
393
+ return False
394
+ if not pills:
395
+ return True
396
+ return _pill_word(row) in pills or (EDITED in pills and row.pending > 0)
397
+
398
+ self.list.filter(keep)
399
+
400
+ def handle(self, event: dict[str, Any]) -> None:
401
+ """The Suite page's rules for its rows (`SuitePage.handle`): a `spec`
402
+ that moved the test set rebuilds them, and one that did not repaints
403
+ the rows whose edits it moved; a scored model repaints its own row.
404
+ Under the status sort, a verdict that moved the order rebuilds
405
+ them."""
406
+ kind = event["event"]
407
+ if kind == "spec":
408
+ if set(self.list.rows) != set(self.page.shell.visible_tests()):
409
+ self._rebuild()
410
+ return
411
+ for model_id in event.get("models") or list(self.list.rows):
412
+ self.list.paint(str(model_id))
413
+ elif kind == "loading":
414
+ if event.get("phase") == "results":
415
+ if not self.list.rows:
416
+ self._rebuild() # a drawer opened before the spec event
417
+ return
418
+ self.list.paint(str(event["model"]))
419
+ elif kind == "results":
420
+ for model_id in event.get("models", ()):
421
+ self.list.paint(str(model_id))
422
+ if self.page.state.tests_sort == "status" and list(self.list.rows) != (
423
+ self._drawn()
424
+ ):
425
+ self._rebuild()
426
+ return
427
+ self._filter()
428
+
429
+
430
+ def _pill_word(row: Row) -> str:
431
+ """The status pill a row counts under: its verdict, SIM_FAIL for
432
+ "scoring failed"."""
433
+ return SIM_FAIL if row.verdict == SCORING_FAILED else row.verdict
434
+
435
+
436
+ def _rank(verdict: str) -> int:
437
+ """Where "Status, failures first" puts a verdict: a word it does not
438
+ list goes last."""
439
+ return (
440
+ FAILURES_FIRST.index(verdict)
441
+ if verdict in FAILURES_FIRST
442
+ else len(FAILURES_FIRST)
443
+ )
444
+
445
+
446
+ class Rail:
447
+ """The rail and its drawers' panels. A drawer's content is built the
448
+ first time it opens."""
449
+
450
+ def __init__(self, page: TestPage) -> None:
451
+ self.page = page
452
+ self.tests: TestsDrawer | None = None
453
+ self.variables: VariablesDrawer | None = None
454
+ self.buttons: dict[str, ui.button] = {}
455
+ self.panels: dict[str, ui.column] = {}
456
+ self.drawer = (
457
+ ui.left_drawer(value=True)
458
+ .props(f"width={RAIL} behavior=desktop bordered")
459
+ .classes("dstf-rail-drawer")
460
+ )
461
+ with (
462
+ self.drawer,
463
+ ui.row().classes("dstf-rail-row no-wrap gap-0 items-stretch w-full h-full"),
464
+ ):
465
+ with ui.column().classes("dstf-rail gap-1 items-center"):
466
+ for kind, icon, text, tip in (
467
+ ("tests", "format_list_bulleted", "Tests", None),
468
+ ("variables", "data_array", "Vars", "Variables"),
469
+ ):
470
+ with (
471
+ ui.button(on_click=lambda k=kind: self.toggle(k))
472
+ .props("flat dense no-caps stack")
473
+ .mark(f"rail:{kind}") as button
474
+ ):
475
+ ui.icon(icon)
476
+ ui.label(text).classes("dstf-rail-label")
477
+ if tip is not None: # "Vars" is short for it
478
+ ui.tooltip(tip)
479
+ self.buttons[kind] = button
480
+ for kind in ("tests", "variables"):
481
+ panel = (
482
+ ui.column()
483
+ .classes("dstf-drawer-panel q-pa-sm gap-2")
484
+ .mark(f"drawer:{kind}")
485
+ )
486
+ panel.set_visibility(False)
487
+ self.panels[kind] = panel
488
+ self.grip = (
489
+ ui.element("div")
490
+ .classes("dstf-drawer-handle")
491
+ .mark("drawer-handle")
492
+ .on(
493
+ "pointerdown",
494
+ lambda e: self.resize(*e.args),
495
+ js_handler=_DRAG,
496
+ )
497
+ .on(
498
+ "keydown",
499
+ js_handler=_KEYDOWN.replace("__MIN__", str(MIN_PANEL))
500
+ .replace("__STEP__", str(_STEP))
501
+ .replace("__BIG__", str(_BIG_STEP)),
502
+ )
503
+ .on(
504
+ "keyup",
505
+ lambda e: self.resize(*e.args),
506
+ js_handler=_KEYUP.replace("__KEYS__", json.dumps(_GRIP_KEYS)),
507
+ )
508
+ .on("blur", lambda e: self.resize(*e.args), js_handler=_BLUR)
509
+ )
510
+ # A window splitter (WAI-ARIA): focusable, its value the panel's
511
+ # width — the most is the browser's (`_WIDTH`'s `bounds`).
512
+ grip = self.grip.props
513
+ grip["role"] = "separator"
514
+ grip["aria-orientation"] = "vertical"
515
+ grip["aria-label"] = "Resize the drawer"
516
+ grip["tabindex"] = "0"
517
+ grip["aria-valuemin"] = str(MIN_PANEL)
518
+ self.grip.set_visibility(False)
519
+ ui.on("dstf_drawer_width", lambda e: self.resize(*e.args))
520
+ ui.run_javascript(
521
+ _WIDTH.replace("__DRAWER__", str(self.drawer.id))
522
+ .replace("__GRIP__", self.grip.html_id)
523
+ .replace("__MIN__", str(MIN_PANEL))
524
+ .replace("__MAX__", str(MAX_FRACTION))
525
+ .replace("__PLOTS__", str(MIN_PLOTS))
526
+ .replace("__RAIL__", str(RAIL))
527
+ )
528
+
529
+ def open(self, kind: str | None) -> None:
530
+ """Show ``kind``'s panel and hide the other; None closes both. The
531
+ drawer widens to hold the panel, which is what narrows the plots.
532
+ The Variables drawer is built again only when it was not the one
533
+ open: an open one follows the page by `refresh`, and building it on
534
+ a select and again here built it twice."""
535
+ was = self.page.state.drawer
536
+ for name, panel in self.panels.items():
537
+ panel.set_visibility(name == kind)
538
+ for name, button in self.buttons.items():
539
+ button.classes(remove="dstf-rail-on")
540
+ if name == kind:
541
+ button.classes(add="dstf-rail-on")
542
+ self.page.state.drawer = kind
543
+ self._width(self.page.state.drawer_width)
544
+ if kind == "tests" and self.tests is None:
545
+ self.tests = TestsDrawer(self.page, self.panels["tests"])
546
+ elif kind == "variables":
547
+ if self.variables is None:
548
+ self.variables = VariablesDrawer(self.page, self.panels["variables"])
549
+ elif was != "variables":
550
+ self.variables.rebuild() # it was not followed while closed
551
+
552
+ def _width(self, width: int) -> None:
553
+ """The drawer's `width` prop and the panels' width: RAIL alone with
554
+ no drawer open."""
555
+ kind = self.page.state.drawer
556
+ self.drawer.props(f"width={RAIL + width if kind else RAIL}")
557
+ for panel in self.panels.values():
558
+ panel.style(f"width: {width}px")
559
+ self.grip.props["aria-valuenow"] = str(width)
560
+ self.grip.set_visibility(kind is not None)
561
+ self.grip.update()
562
+ if kind is not None:
563
+ # The tab's width may be one a wider window held: the browser
564
+ # clamps it to this one's and sends it back (`fit`).
565
+ self.page.client.run_javascript(f"window.__dstfDrawer?.fit({width})")
566
+
567
+ def resize(self, width: Any, window: Any) -> None:
568
+ """A drag's release (`_DRAG`): the width the browser clamped and the
569
+ window's width, held to the same bounds again here (`max_panel`,
570
+ then the floor), kept in `TabState`."""
571
+ try:
572
+ px = int(round(min(float(width), max_panel(float(window)))))
573
+ except (TypeError, ValueError, OverflowError):
574
+ return
575
+ px = max(MIN_PANEL, px)
576
+ self.page.state.drawer_width = px
577
+ self._width(px)
578
+
579
+ def toggle(self, kind: str) -> None:
580
+ self.open(None if self.page.state.drawer == kind else kind)
581
+
582
+ def refresh(self) -> None:
583
+ """The open Variables drawer follows the page — the plot selected,
584
+ the layout staged, the result read — and builds again only when one
585
+ of those moved (`VariablesDrawer.refresh`). The ONE way the page's
586
+ changes reach it: `TestPage.select` and the end of `TestPage.sync`
587
+ call this. A closed one is left alone; it is rebuilt when it opens."""
588
+ if self.page.state.drawer == "variables" and self.variables is not None:
589
+ self.variables.refresh()
590
+
591
+ def handle(self, event: dict[str, Any]) -> None:
592
+ if self.tests is not None:
593
+ self.tests.handle(event)