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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|