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,373 @@
1
+ """The Suite table: an AG Grid Community grid (`ui.aggrid`, AG Grid 34.2 as
2
+ NiceGUI 3.16 bundles it) over the row model's columns (`reporting.rows`).
3
+ Its filtering, sorting, grouping and the toolbar that drives them run in
4
+ the browser (`suite_grid.js`, through the typed filters of
5
+ `reporting/templates/filters.js`); Python sends rows and receives the
6
+ selection.
7
+
8
+ The grid is created ONCE per page and afterwards driven only through
9
+ `run_grid_method` / `run_javascript`: NiceGUI's aggrid re-creates the whole
10
+ grid on any Python-side update of the element (`aggrid.js`: its
11
+ `_update_method` is `update_grid`, which destroys the grid and builds a new
12
+ one from the element's options), losing the sort, the selection and every
13
+ row patched since. `SuiteGrid.seal()` makes that loud."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Callable, Iterable, Sequence
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from nicegui import ui
23
+
24
+ from ...reporting.rows import COLUMNS, EDITS, FILTER_HINTS, Column, TestRow
25
+ from ...reporting.status import (
26
+ FAIL,
27
+ NO_REF,
28
+ NOT_RUN,
29
+ PASS,
30
+ QUEUED,
31
+ RUNNING,
32
+ SCORING,
33
+ SCORING_CLASS,
34
+ SIM_FAIL,
35
+ STATUS_CLASSES,
36
+ STATUS_LABELS,
37
+ )
38
+
39
+ HERE = Path(__file__).parent
40
+ #: The typed filters, one module for the dashboard and this table.
41
+ FILTERS_JS = HERE.parent.parent / "reporting" / "templates" / "filters.js"
42
+ #: Where the shell serves the two scripts (`Shell.mount`).
43
+ ASSETS = "/_dstf"
44
+ #: A per-viewer key for the table's view (localStorage), per project.
45
+ STORAGE = "dstf-suite-v1"
46
+
47
+ #: A live run's columns (the queue panel shows a running test's): always
48
+ #: "—" in the tool, so not even the column chooser offers them.
49
+ LIVE_RUN = ("test", "worker", "elapsed")
50
+ TOOL_COLUMNS: tuple[Column, ...] = (
51
+ *(c for c in COLUMNS if c.key not in LIVE_RUN),
52
+ EDITS,
53
+ )
54
+ #: Shown by default: the verdict, the test, its numbers, its edits and its
55
+ #: message — Edits before Message, which takes the width left over, so a
56
+ #: narrow window cuts the message, not the edits. The rest are in the
57
+ #: column chooser.
58
+ VISIBLE = (
59
+ "status",
60
+ "model",
61
+ "nrmse",
62
+ "criteria",
63
+ "sim",
64
+ "warnings",
65
+ "edits",
66
+ "detail",
67
+ )
68
+ ORDER = (*VISIBLE, "ref", "resolution", "translate", "total")
69
+ #: What a filter says beyond its kind's hint (``rows.FILTER_HINTS``, the
70
+ #: dashboard's words): the Test column shows short names and filters the
71
+ #: full one.
72
+ FILTER_NOTE = {"model": "Reads the full test name. "}
73
+ #: The count pills, ``[key, word, passing]`` in the order the toolbar draws
74
+ #: them: a status by its class (a row's ``status_class``), EDITED (a row
75
+ #: with staged edits, whatever its status), then the three a row only
76
+ #: passes through (``passing``: drawn only while a row counts under it, or
77
+ #: it filters) — SCORING, a row not scored yet, and the queue's QUEUED and
78
+ #: RUNNING. The words are ``reporting.status``'s: the script reads this
79
+ #: table (``window.DstfSuitePills``) and spells none of them, nor which
80
+ #: come and go.
81
+ PILLS: tuple[tuple[str, str, bool], ...] = (
82
+ *(
83
+ (STATUS_CLASSES[s], STATUS_LABELS[s], False)
84
+ for s in (PASS, FAIL, SIM_FAIL, NO_REF, NOT_RUN)
85
+ ),
86
+ ("edited", "EDITED", False),
87
+ (SCORING_CLASS, SCORING, True),
88
+ (STATUS_CLASSES[QUEUED], STATUS_LABELS[QUEUED], True),
89
+ (STATUS_CLASSES[RUNNING], STATUS_LABELS[RUNNING], True),
90
+ )
91
+
92
+
93
+ def row_json(row: TestRow) -> dict[str, Any]:
94
+ """One row as the grid holds it: every column's text and sort value
95
+ (a number filter reads the sort value, never the text), the package it
96
+ groups under and its two names."""
97
+ package, _, short = row.model_id.rpartition(".")
98
+ data: dict[str, Any] = {
99
+ "id": row.model_id,
100
+ "_pkg": package,
101
+ "_name_short": short,
102
+ "_name_full": row.model_id,
103
+ "status_class": row.status_class,
104
+ }
105
+ for c in TOOL_COLUMNS:
106
+ data[c.key] = c.text(row)
107
+ data[f"{c.key}__sort"] = c.sort(row)
108
+ # A number of another kind (Sim (s)'s CPU time): its tag and tooltip.
109
+ for part, get in (("tag", c.tag), ("tip", c.tip)):
110
+ value = get(row) if get is not None else None
111
+ if value is not None:
112
+ data[f"{c.key}__{part}"] = value
113
+ return data
114
+
115
+
116
+ def _filter_params(c: Column) -> dict[str, str]:
117
+ """A column's filter input: its kind, and the dashboard's placeholder
118
+ and tooltip for that kind (the status column's cell mirrors the pills,
119
+ and has neither)."""
120
+ if c.kind == "status":
121
+ return {"kind": c.kind}
122
+ placeholder, title = FILTER_HINTS[c.kind]
123
+ return {
124
+ "kind": c.kind,
125
+ "placeholder": placeholder,
126
+ "help": FILTER_NOTE.get(c.key, "") + title,
127
+ }
128
+
129
+
130
+ def _column_defs() -> list[dict[str, Any]]:
131
+ by_key = {c.key: c for c in TOOL_COLUMNS}
132
+ defs: list[dict[str, Any]] = []
133
+ for key in ORDER:
134
+ c = by_key[key]
135
+ d: dict[str, Any] = {
136
+ "field": c.key,
137
+ "colId": c.key,
138
+ "headerName": c.label,
139
+ "hide": c.key not in VISIBLE,
140
+ ":comparator": f"DstfSuite.comparator({json.dumps(c.key)})",
141
+ "floatingFilter": True,
142
+ ":filter": "DstfSuite.NoFilter",
143
+ ":floatingFilterComponent": "DstfSuite.FloatingInput",
144
+ "floatingFilterComponentParams": _filter_params(c),
145
+ "suppressFloatingFilterButton": True,
146
+ "suppressHeaderFilterButton": True,
147
+ "suppressHeaderMenuButton": True,
148
+ # Narrow enough that Edits is in view at 900 px (Message after
149
+ # it takes the width left over, and may be cut).
150
+ "width": 90,
151
+ }
152
+ if c.title:
153
+ d["headerTooltip"] = c.title
154
+ if c.kind == "number":
155
+ d["type"] = "rightAligned"
156
+ d["cellClass"] = "dstf-num"
157
+ if c.tag is not None:
158
+ d[":cellRenderer"] = "DstfSuite.cell.tagged"
159
+ d[":tooltipValueGetter"] = (
160
+ f"(p) => p.data && p.data[{json.dumps(c.key + '__tip')}]"
161
+ )
162
+ if key == "status":
163
+ d[":cellRenderer"] = "DstfSuite.cell.status"
164
+ d["width"] = 100 # "SIM_FAIL" in its badge and the sort arrow
165
+ elif key == "model":
166
+ d[":cellRenderer"] = "DstfSuite.cell.model"
167
+ # Twice Message's share: 3 of TRANSFORM's 5 longest short names
168
+ # were cut at 1280 px with an equal share; at twice it the
169
+ # longest (47 characters) reads whole in 359 px. The minimum
170
+ # holds Edits in view at 900 px: every column before it is at
171
+ # its width there, and 180 put Edits 21 px past the viewport.
172
+ d.update(flex=2, minWidth=150)
173
+ del d["width"]
174
+ # A name cut at the cell's edge reads in full on hover — the
175
+ # whole cell's tooltip, not only its link's.
176
+ d[":tooltipValueGetter"] = "(p) => p.data && p.data.id"
177
+ elif key == "nrmse":
178
+ # "1.5309e-01" and its header: 110 px cut both at 1280.
179
+ d["width"] = 135
180
+ elif key == "detail":
181
+ d.update(flex=1, minWidth=160)
182
+ del d["width"]
183
+ # A message cut at the cell's edge reads in full on hover; a
184
+ # row with none ("—") has no tooltip.
185
+ d[":tooltipValueGetter"] = (
186
+ "(p) => p.data && p.data.detail__sort != null ? p.value : null"
187
+ )
188
+ elif key == "edits":
189
+ d[":cellRenderer"] = "DstfSuite.cell.edits"
190
+ d["width"] = 120 # "1 unsaved" in its tag: 100 cut it
191
+ defs.append(d)
192
+ return defs
193
+
194
+
195
+ class SuiteGrid(ui.aggrid):
196
+ """The grid element. The page calls `seal()` after its last build-time
197
+ call on it (`.on`, `.classes`, `.mark`): from then on `update()` raises.
198
+
199
+ `update()` is the only way an element change reaches the browser —
200
+ NiceGUI's first render enqueues the element straight from
201
+ `Element.__init__` (`outbox.enqueue_update`), never through it — so the
202
+ guard sits there. `.classes` / `.props` / `.style` reach it through an
203
+ observable whose handler LOGS the exception (`events.handle_event`):
204
+ the update is blocked, but such a caller sees no raise, only the log."""
205
+
206
+ def __init__(
207
+ self,
208
+ rows: Sequence[TestRow],
209
+ *,
210
+ selected: Iterable[str],
211
+ on_selection: Callable[[list[str]], None],
212
+ ) -> None:
213
+ kinds = {c.key: c.kind for c in TOOL_COLUMNS}
214
+ # Before NiceGUI's __init__: it writes props, and a prop write
215
+ # comes through `update` below.
216
+ self._sealed = False
217
+ super().__init__(
218
+ {
219
+ "columnDefs": _column_defs(),
220
+ "rowData": [row_json(r) for r in rows],
221
+ ":getRowId": "(p) => p.data.id",
222
+ "defaultColDef": {"sortable": True, "resizable": True},
223
+ "rowHeight": 30,
224
+ "headerHeight": 32,
225
+ "floatingFiltersHeight": 32,
226
+ "tooltipShowDelay": 300,
227
+ ":isFullWidthRow": "(p) => !!p.rowNode.data?._g",
228
+ ":fullWidthCellRenderer": "DstfSuite.GroupRow",
229
+ ":getRowClass": "DstfSuite.rowClass",
230
+ "rowSelection": {
231
+ "mode": "multiRow",
232
+ "headerCheckbox": True,
233
+ "selectAll": "filtered",
234
+ "enableClickSelection": False,
235
+ ":isRowSelectable": "(n) => !n.data?._g",
236
+ },
237
+ ":isExternalFilterPresent": "() => true",
238
+ ":doesExternalFilterPass": "DstfSuite.externalPass",
239
+ ":postSortRows": "DstfSuite.postSort",
240
+ ":onCellKeyDown": "DstfSuite.keyDown",
241
+ ":onSortChanged": "() => DstfSuite.saveState()",
242
+ ":onColumnVisible": "() => DstfSuite.saveState()",
243
+ ":onSelectionChanged": "() => DstfSuite.renderSelection()",
244
+ "animateRows": False,
245
+ # The tab's selection, selected once the grid is ready.
246
+ ":onGridReady": (
247
+ f"(p) => DstfSuite.ready(p, {json.dumps(kinds)}, "
248
+ f"{json.dumps(sorted(selected))})"
249
+ ),
250
+ },
251
+ auto_size_columns=False,
252
+ )
253
+ self.classes("dstf-suite-grid w-full")
254
+ self.on(
255
+ "selectionChanged",
256
+ lambda e: on_selection(list(e.args or [])),
257
+ js_handler=(
258
+ f"() => emit(getElement({self.id}).api.getSelectedRows()"
259
+ ".filter(r => !r._g).map(r => r.id))"
260
+ ),
261
+ )
262
+
263
+ def seal(self) -> None:
264
+ self._sealed = True
265
+
266
+ def transaction(
267
+ self,
268
+ *,
269
+ add: Sequence[TestRow] = (),
270
+ update: Sequence[TestRow] = (),
271
+ remove: Iterable[str] = (),
272
+ select: Iterable[str] = (),
273
+ ) -> None:
274
+ """One AG Grid transaction, through the table's script (which then
275
+ regroups and recounts): rows to add, rows to repaint, ids to drop,
276
+ and the added ids the tab has selected."""
277
+ tx = {
278
+ "add": [row_json(r) for r in add],
279
+ "update": [row_json(r) for r in update],
280
+ "remove": list(remove),
281
+ "select": list(select),
282
+ }
283
+ self.client.run_javascript(f"DstfSuite.apply({json.dumps(tx)})")
284
+
285
+ def update(self) -> None:
286
+ if self._sealed:
287
+ raise RuntimeError(
288
+ "the Suite grid is driven by run_grid_method; an element "
289
+ "update rebuilds it"
290
+ )
291
+ super().update()
292
+
293
+
294
+ def scripts_html(key: str) -> str:
295
+ """The head of the Suite page: the table's localStorage key and its
296
+ count pills (`PILLS`), then the typed filters and the table's script.
297
+ It must be in the page's FIRST HTML: after the handshake NiceGUI
298
+ inserts head HTML with insertAdjacentHTML, which never runs a
299
+ <script>."""
300
+
301
+ # "</" would end the inline script early; JSON may escape it.
302
+ def literal(value: object) -> str:
303
+ return json.dumps(value).replace("</", "<\\/")
304
+
305
+ return (
306
+ f"<script>window.DstfSuiteKey = {literal(key)};\n"
307
+ f"window.DstfSuitePills = {literal(PILLS)};</script>\n"
308
+ f'<script src="{ASSETS}/filters.js"></script>\n'
309
+ f'<script src="{ASSETS}/suite_grid.js"></script>\n'
310
+ )
311
+
312
+
313
+ GRID_CSS = """
314
+ .dstf-suite-grid { height: calc(100vh - 190px); min-height: 320px;
315
+ --ag-font-family: var(--dstf-font); --ag-font-size: 13px;
316
+ --ag-header-background-color: var(--dstf-surface-hover);
317
+ --ag-border-color: var(--dstf-border); --ag-row-border-color: var(--dstf-border-hairline);
318
+ --ag-accent-color: var(--dstf-accent); --ag-selected-row-background-color: var(--dstf-selection);
319
+ --ag-wrapper-border-radius: 4px; }
320
+ /* A dark theme (D148): NiceGUI turns the grid's own colour scheme with
321
+ `body--dark`, and AG Grid's dark scheme is blue-tinted with white text.
322
+ The grid's ground, text and controls are the theme's instead. A light
323
+ theme keeps AG Grid's light scheme as it was. */
324
+ .body--dark .dstf-suite-grid {
325
+ --ag-background-color: var(--dstf-surface); --ag-foreground-color: var(--dstf-text);
326
+ --ag-text-color: var(--dstf-text); --ag-header-text-color: var(--dstf-text);
327
+ --ag-subtle-text-color: var(--dstf-text-muted);
328
+ --ag-odd-row-background-color: var(--dstf-surface);
329
+ --ag-row-hover-color: var(--dstf-surface-hover);
330
+ --ag-chrome-background-color: var(--dstf-surface-hover);
331
+ --ag-header-column-resize-handle-color: var(--dstf-border);
332
+ --ag-input-background-color: var(--dstf-surface); --ag-input-border-color: var(--dstf-border);
333
+ --ag-checkbox-unchecked-border-color: var(--dstf-text-muted);
334
+ --ag-tooltip-background-color: var(--dstf-surface-active); }
335
+ .body--dark .dstf-ff { background: var(--dstf-surface); color: var(--dstf-text); }
336
+ .dstf-suite-grid .dstf-num { font-family: var(--dstf-mono); }
337
+ .dstf-grp { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 8px;
338
+ font-weight: 600; background: var(--dstf-surface-hover); cursor: pointer; }
339
+ .dstf-grp .material-icons { font-size: 18px; }
340
+ .dstf-grp-count { font-weight: 400; color: var(--dstf-text-muted); margin-left: 8px; }
341
+ .dstf-badge-cell { color: #fff; font-size: 11px; font-weight: 700; padding: 1px 6px;
342
+ border-radius: 3px; }
343
+ .dstf-edits { font-size: 11px; padding: 0 6px; border-radius: 3px;
344
+ border: 1px solid var(--dstf-warn-border); background: var(--dstf-warn-pill);
345
+ color: var(--dstf-warn-dark); }
346
+ .dstf-ff { width: 100%; font-size: 12px; border: 1px solid var(--dstf-border);
347
+ border-radius: 2px; padding: 1px 4px; }
348
+ .dstf-ff.bad { border-color: var(--dstf-fail-fill); outline: 1px solid var(--dstf-fail-fill); }
349
+ .dstf-ff-status { font-size: 11px; color: var(--dstf-text-muted); }
350
+ .dstf-cell-tag { font-size: 11px; color: var(--dstf-text-muted); margin-left: 3px; }
351
+ /* The pills and the view icons wrap at a narrow width; the selection and
352
+ the actions at the right keep their line. */
353
+ #dstf-suite-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
354
+ flex: 1 1 auto; min-width: 0; }
355
+ #dstf-suite-selection { display: inline-flex; align-items: center; gap: 4px; font-size: 13px;
356
+ white-space: nowrap; flex: none; }
357
+ .dstf-suite-toolbar > .dstf-actions { flex-wrap: nowrap; flex: none; }
358
+ .dstf-pills, .dstf-views { display: inline-flex; flex-wrap: wrap; gap: 4px; align-items: center; }
359
+ .dstf-tb { border: 1px solid var(--dstf-border); background: var(--dstf-surface);
360
+ color: var(--dstf-text); border-radius: 4px; min-height: 28px; padding: 0 6px;
361
+ cursor: pointer; display: inline-flex; align-items: center; }
362
+ .dstf-tb .material-icons { font-size: 18px; }
363
+ .dstf-tb.on { outline: 2px solid var(--dstf-accent); }
364
+ .dstf-tb:disabled { opacity: .5; cursor: default; }
365
+ .dstf-tb-txt { font-size: 12px; font-family: var(--dstf-mono); }
366
+ /* A tooltip reads a whole test id or message: an id has no space to wrap
367
+ at, and at 900 px a 149-character one ran 250 px past the page's edge. */
368
+ .ag-tooltip { max-width: min(90vw, 720px); white-space: normal; overflow-wrap: anywhere; }
369
+ #dstf-colmenu { position: absolute; z-index: 9000; background: var(--dstf-surface);
370
+ border: 1px solid var(--dstf-border); border-radius: 4px; padding: 6px 10px;
371
+ display: flex; flex-direction: column; gap: 2px; font-size: 13px;
372
+ box-shadow: 0 2px 8px var(--dstf-shadow-menu); }
373
+ """
@@ -0,0 +1,110 @@
1
+ """The tab's theme on one page (D148): the project's (`testing.json`
2
+ ``"theme"``) until the tab switches, then the tab's own — kept in its
3
+ `TabState`, so a reload and the tab's other page keep it, and a second tab
4
+ on the same server is not touched.
5
+
6
+ The switch is LIVE, with no reload: the `:root` token block is one
7
+ `<style>` this module owns and rewrites in place, Quasar's palette and its
8
+ dark mode follow, and the page then re-sends what the server drew with a
9
+ theme's literals in it — its plots. A drawn plot takes its new figure as a
10
+ `Plotly.react` on the same node (`PlotCard.redraw`), which keeps the zoom,
11
+ the drag config, the arrows' map and a staged edit. `Plotly.relayout` with
12
+ a template does not: it leaves the ring and the context series in the old
13
+ theme's colours, and the next server redraw paints the old ground again
14
+ (measured in Chromium on the tool's plotly.js 3.1.1, 2026-10-08).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections.abc import Callable
21
+ from typing import TYPE_CHECKING
22
+
23
+ from nicegui import ui
24
+
25
+ from ...config import ConfigError, load_config_file
26
+ from ...reporting.theme import THEMES, css_variables, is_dark, quasar_palette
27
+ from .tabstate import TabState
28
+
29
+ if TYPE_CHECKING:
30
+ from .shell import Shell
31
+
32
+ #: The id of the `<style>` holding the theme's `:root` block.
33
+ STYLE_ID = "dstf-theme"
34
+
35
+
36
+ def project_theme(shell: Shell) -> str:
37
+ """The theme a tab starts in: the open project's, and before the open
38
+ has built its `Config` — the first page is drawn before a result is
39
+ read — what its testing.json says. A file the open will refuse reads
40
+ `default` here: the page's progress line says why it did not open."""
41
+ project = shell.session.project
42
+ if project is not None:
43
+ return project.config.theme
44
+ try:
45
+ said = load_config_file(shell.config_path).get("theme")
46
+ except (ConfigError, AttributeError):
47
+ return "default"
48
+ return said if isinstance(said, str) and said in THEMES else "default"
49
+
50
+
51
+ def first_paint(shell: Shell) -> None:
52
+ """The page's ground in its first HTML — called BEFORE the handshake,
53
+ as a page's scripts are. Which tab this is, and so its theme, is known
54
+ only once the browser has connected; until then the window was white,
55
+ 0.2-1.1 s of every load in a dark project (measured in Chromium). The
56
+ PROJECT's ground stands in: right for every tab that has not switched,
57
+ and the shell's own `html` rule, laid with the tab's tokens, follows it
58
+ in the cascade and wins."""
59
+ page = THEMES[project_theme(shell)]["page"]
60
+ ui.add_head_html(f"<style>html {{ background: {page}; }}</style>")
61
+
62
+
63
+ class TabTheme:
64
+ def __init__(
65
+ self,
66
+ shell: Shell,
67
+ state: TabState,
68
+ switched: Callable[[], None] | None = None,
69
+ ) -> None:
70
+ """Lay the tab's theme on the page being built: Quasar's palette
71
+ and dark mode, and the token block. ``switched`` is the page's own
72
+ follow-up to a switch — what it must re-send."""
73
+ self.shell, self.state, self._switched = shell, state, switched
74
+ self.client = ui.context.client
75
+ name = self.name
76
+ ui.colors(**quasar_palette(name))
77
+ # Quasar's dark mode beside the tokens: what Quasar draws itself (a
78
+ # menu, a field, a dialog's backdrop) and AG Grid, which NiceGUI
79
+ # switches off `body--dark`, follow it.
80
+ self._dark = ui.dark_mode(is_dark(name))
81
+ ui.add_head_html(f'<style id="{STYLE_ID}">{css_variables(name)}</style>')
82
+
83
+ @property
84
+ def name(self) -> str:
85
+ return self.state.theme or project_theme(self.shell)
86
+
87
+ @property
88
+ def other(self) -> str:
89
+ """The bundled theme a switch goes to: the next one, around."""
90
+ names = list(THEMES)
91
+ return names[(names.index(self.name) + 1) % len(names)]
92
+
93
+ def switch(self) -> None:
94
+ name = self.other
95
+ self.state.theme = name
96
+ # `ui.colors` sets Quasar's variables once, when it mounts: a
97
+ # switch sets them where it did.
98
+ palette = {
99
+ f"--q-{key.replace('_', '-')}": value
100
+ for key, value in quasar_palette(name).items()
101
+ }
102
+ self.client.run_javascript(
103
+ f"document.getElementById({json.dumps(STYLE_ID)}).textContent = "
104
+ f"{json.dumps(css_variables(name))};"
105
+ f"for (const [k, v] of Object.entries({json.dumps(palette)})) "
106
+ "document.body.style.setProperty(k, v);"
107
+ )
108
+ self._dark.value = is_dark(name)
109
+ if self._switched is not None:
110
+ self._switched()
@@ -0,0 +1,146 @@
1
+ """Selection state per browser tab (brief §3.2; D129): what the Suite table
2
+ has selected (its filters, sort and layout are the viewer's, in the
3
+ browser's localStorage — `suite_grid.js`). Kept in NiceGUI's tab storage, so
4
+ it is there after navigating to a Test page and back within the tab, and a
5
+ second tab on the same server starts with its own. Volatile: gone when the
6
+ tab closes. Never the Session's — that is the decision."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from nicegui import app
14
+
15
+ KEY = "dstf"
16
+
17
+
18
+ @dataclass
19
+ class TabState:
20
+ checked: set[str] = field(default_factory=set)
21
+ #: The theme this tab switched to (D148; `tab_theme.py`), a bundled
22
+ #: name; None until it switches: the project's. The tab's, so a reload
23
+ #: and the tab's other page keep it and another tab does not get it.
24
+ theme: str | None = None
25
+ #: The Test page's open drawer — "tests", "variables" or None — and
26
+ #: every control of both drawers (3c; step T, Q7): the tab's, so a
27
+ #: switch of test, which builds new drawers, keeps them.
28
+ drawer: str | None = None
29
+ #: The open drawer's panel width in px, dragged by its right edge (K5):
30
+ #: the tab's, so a switch of test keeps it.
31
+ drawer_width: int = 320
32
+ tests_needle: str = ""
33
+ tests_regex: bool = False
34
+ tests_case: bool = False
35
+ tests_sort: str = "package" # "package" | "name" | "status"
36
+ tests_grouped: bool = True
37
+ tests_short: bool = True
38
+ #: The status pills pressed (a status word, or "EDITED"); none = all.
39
+ tests_pills: set[str] = field(default_factory=set)
40
+ vars_needle: str = ""
41
+ vars_regex: bool = False
42
+ vars_case: bool = False
43
+ vars_group: str = "component" # "component" | "flat" | "tracked"
44
+ vars_names: str = "both" # "expression" | "file" | "both"
45
+ vars_sort: str = "file" # "file" | "name"
46
+ #: The tree card's folded groups (U5, Q8 / Y6): model id -> the folded
47
+ #: groups' node paths. The tab's, so a switch of test keeps them.
48
+ tree_folded: dict[str, list[str]] = field(default_factory=dict)
49
+ #: The plots' unfolded family rows (D152): model id -> the paths of
50
+ #: their members. A folded row starts closed, so this records the open
51
+ #: ones; kept by member, so a fold whose first member left it is still
52
+ #: the one that was opened. The tab's, and taken away as the tree's
53
+ #: folds are (`heard_spec`).
54
+ family_unfolded: dict[str, list[str]] = field(default_factory=dict)
55
+ #: What a panel shows that the spec does not hold (`Panel.kept`): model
56
+ #: id -> (node path, capability name) -> that panel's own dict. The
57
+ #: tab's, as the folds are, and taken away as they are (`heard_spec`):
58
+ #: with the test's entry when an edit that is not this tab's lands on it.
59
+ kept: dict[str, dict[tuple[str, str], dict[Any, Any]]] = field(default_factory=dict)
60
+ #: The `spec` revisions this tab's own acts produced (`TestPage._own`),
61
+ #: kept until a later revision is heard. Any other `spec` event for a
62
+ #: test unfolds its groups (the owner's ruling, U5 H2; `heard_spec`).
63
+ #: The tab's, not a page's: a page navigated away from stays registered
64
+ #: until NiceGUI's reconnect timeout and hears every event, and with a
65
+ #: record of its own it heard the new page's edit as foreign and
66
+ #: cleared the folds.
67
+ own_revisions: set[int] = field(default_factory=set)
68
+ #: The last `spec` revision any page of the tab heard or produced: a
69
+ #: page opening in a tab that had no live page compares it with the
70
+ #: Session's (`caught_up`).
71
+ seen_revision: int = 0
72
+
73
+
74
+ def heard_spec(state: TabState, event: dict[str, Any]) -> None:
75
+ """A `spec` event, heard by a page of the tab — EVERY page, the Suite
76
+ page too (K1): one this tab's own edits did not produce unfolds every
77
+ group of each test it names, of every test when it names none (a
78
+ close): their folds followed only the tab's edits (`TestPage.edited`)
79
+ — a Discard, a Save that reloads, another tab's edit, a spec reload.
80
+ What its panels kept beside the spec (`TabState.kept`) goes the same
81
+ way: it was typed against an entry that event replaced.
82
+ Heard only by a page of that test, a Discard from another test's
83
+ header left its folds on the groups the Discard put back. Every page
84
+ of the tab hears this event and reads the same record, so the event's
85
+ own revision stays in it and only those BELOW it go: the Shell hands
86
+ each event to every page before the next (`Shell.tick`), so every page
87
+ has heard them."""
88
+ revision = event["revision"]
89
+ own_revisions = state.own_revisions
90
+ own = revision in own_revisions
91
+ own_revisions.difference_update([r for r in own_revisions if r < revision])
92
+ state.seen_revision = max(state.seen_revision, revision)
93
+ if own:
94
+ return
95
+ models = event.get("models")
96
+ if not models:
97
+ state.tree_folded.clear()
98
+ state.family_unfolded.clear()
99
+ state.kept.clear()
100
+ for model in models or ():
101
+ state.tree_folded.pop(model, None)
102
+ state.family_unfolded.pop(model, None)
103
+ state.kept.pop(model, None)
104
+
105
+
106
+ def caught_up(state: TabState, revision: int) -> None:
107
+ """A page of the tab opens with the Session at ``revision`` (K2): a tab
108
+ with no live page heard nothing, so a revision after the last it saw
109
+ that none of its acts produced may have changed any test — every fold
110
+ goes, and what the panels kept, as `heard_spec` would have taken them."""
111
+ missed = set(range(state.seen_revision + 1, revision + 1))
112
+ if missed - state.own_revisions:
113
+ state.tree_folded.clear()
114
+ state.family_unfolded.clear()
115
+ state.kept.clear()
116
+ state.seen_revision = max(state.seen_revision, revision)
117
+
118
+
119
+ async def connected_tab_state(client: Any) -> TabState | None:
120
+ """This tab's state once ``client`` has a live connection, or None when
121
+ the client was deleted first — every page awaits this before drawing,
122
+ and returns on None: there is no browser to draw for.
123
+
124
+ ``Client.connected()`` also returns when the client is DELETED: a plain
125
+ GET (the tests' readiness probe, a curl) makes a client that never
126
+ connects, NiceGUI prunes it a minute later, and `tab_state` raised
127
+ "app.storage.tab can only be used with a client connection" from the
128
+ page's build. And a handshake followed by a disconnect before the build
129
+ resumed (a loop held up past the ping budget) leaves a live client with
130
+ no tab until the browser reconnects: that one is waited for again."""
131
+ while not client.has_socket_connection:
132
+ if client.is_deleted:
133
+ return None
134
+ await client.connected()
135
+ return tab_state()
136
+
137
+
138
+ def tab_state() -> TabState:
139
+ """This tab's state, created on first use. Needs a connected client:
140
+ a page's build asks `connected_tab_state`."""
141
+ store = app.storage.tab
142
+ state = store.get(KEY)
143
+ if not isinstance(state, TabState):
144
+ state = TabState()
145
+ store[KEY] = state
146
+ return state