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,589 @@
|
|
|
1
|
+
"""The Variables drawer (the roadmap's 3c; DESIGN.md "The Variables drawer";
|
|
2
|
+
the owner's rulings Q5-Q7, 2026-09-25, and Q12-Q14, 2026-09-28): every
|
|
3
|
+
variable of the last result in `variable_rows`' groups, folded and
|
|
4
|
+
filtered, each row checked against the selected plot. Its filter and its
|
|
5
|
+
sort, group and names menus are the tab's (`TabState`), drawn by
|
|
6
|
+
`drawer_controls` as the Tests drawer's are. It edits the plot extras: a
|
|
7
|
+
check adds a row's members to the selected plot's CONTEXT or takes them
|
|
8
|
+
off (the plot's subject is on and grey — a derived plot's subject is the
|
|
9
|
+
tree's), "+ new plot" makes a WATCH plot of one variable after the others.
|
|
10
|
+
The rules are not here: the rows are data (`authoring.variable_rows`), the
|
|
11
|
+
edits pure functions (`authoring.layout`), and the page stages what they
|
|
12
|
+
return (`TestPage.stage_layout`).
|
|
13
|
+
|
|
14
|
+
Rebuilt whole when it opens, and when what it draws moved under the page
|
|
15
|
+
it sits on (`Rail.refresh`: a select, a sync), never while closed. So its
|
|
16
|
+
check is a button with `role=checkbox`, not a `ui.checkbox`: a checkbox's
|
|
17
|
+
`set_value` fires its own `on_change`, and a repaint of one would stage an
|
|
18
|
+
edit."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, replace
|
|
26
|
+
from functools import partial
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
import regex
|
|
30
|
+
from nicegui import ui
|
|
31
|
+
|
|
32
|
+
from ...backends.types import VariableText
|
|
33
|
+
from ...discovery.plots import PlotExtra, ResolvedPlot
|
|
34
|
+
from ...discovery.unit_tests import is_unit_test_variable
|
|
35
|
+
from ...figures.families import HUES
|
|
36
|
+
from ..layout import add_context, new_watch, remove_context
|
|
37
|
+
from ..needle import matcher
|
|
38
|
+
from ..variable_rows import VarGroup, VarRow, cap, variable_groups
|
|
39
|
+
from .drawer_controls import filter_box, menu_button
|
|
40
|
+
from .widgets import MiddleCut
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from .test_page import TestPage
|
|
44
|
+
|
|
45
|
+
#: At most this many top-level rows (planner's choice 4): a result of
|
|
46
|
+
#: thousands of names drew thousands of rows. An opened family shows at most
|
|
47
|
+
#: ``MEMBERS`` of its own.
|
|
48
|
+
ROWS, MEMBERS = 200, 50
|
|
49
|
+
|
|
50
|
+
#: A family's swatch marks the row as an array: four blocks, one per stop
|
|
51
|
+
#: of the accent ramp (the first hue's, `figures.families`), light to dark,
|
|
52
|
+
#: as the owner-approved board drew it — discrete, never a gradient
|
|
53
|
+
#: (DESIGN.md "Don't use gradients"). It is not the ramp its members are
|
|
54
|
+
#: drawn in: a row is tied to no plot, and a plot gives a family its hue by
|
|
55
|
+
#: the family's order on that plot.
|
|
56
|
+
_RAMP = HUES[0]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_SPACE = re.compile(r"\s+")
|
|
60
|
+
|
|
61
|
+
#: The target line and the grey "+ new plot"'s reason on a run that failed
|
|
62
|
+
#: (`TestPage.run_state`). A test with no result reads as one never run.
|
|
63
|
+
RUN_FAILED = "The run failed: no plots"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def marker(kind: str, row_id: str) -> str:
|
|
67
|
+
"""A row's ``var-*`` marker: ``kind:row_id`` with ALL whitespace taken
|
|
68
|
+
out. NiceGUI splits a marker on whitespace, so ``port[2, 1].C[]``'s was
|
|
69
|
+
two words that ``port[2, 3, 1].C[]``'s marker also carried, and a find
|
|
70
|
+
for the one found both. Every row marker is built here; a test looks one
|
|
71
|
+
up through it."""
|
|
72
|
+
return f"{kind}:{_SPACE.sub('', row_id)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class _Reads:
|
|
77
|
+
"""The Session's reads one build draws from, taken once."""
|
|
78
|
+
|
|
79
|
+
names: list[str]
|
|
80
|
+
tracked: list[str]
|
|
81
|
+
scope: dict[str, VariableText]
|
|
82
|
+
plots: tuple[ResolvedPlot, ...]
|
|
83
|
+
#: the names that are the test's UnitTests elements: rows of their own,
|
|
84
|
+
#: as they are plots of their own (O3)
|
|
85
|
+
unit_tests: frozenset[str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _slug(title: str) -> str:
|
|
89
|
+
"""A group title as one marker word: NiceGUI splits markers on
|
|
90
|
+
whitespace, and "(top level)" was two (the executing review)."""
|
|
91
|
+
return re.sub(r"[^\w.$]+", "-", title).strip("-")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _state(row: VarRow) -> tuple[str, str]:
|
|
95
|
+
"""``(aria-checked, icon)``: all, some or none of the row's members in
|
|
96
|
+
the selected plot's context — or the plot's subject, which it always
|
|
97
|
+
shows."""
|
|
98
|
+
if row.is_subject or row.on_selected == len(row.members):
|
|
99
|
+
return "true", "check_box"
|
|
100
|
+
if row.on_selected:
|
|
101
|
+
return "mixed", "indeterminate_check_box"
|
|
102
|
+
return "false", "check_box_outline_blank"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class VariablesDrawer:
|
|
106
|
+
def __init__(self, page: TestPage, panel: ui.column) -> None:
|
|
107
|
+
self.page = page
|
|
108
|
+
#: The families opened to their members, by `_family`: kept across
|
|
109
|
+
#: rebuilds, so a check inside an opened family leaves it open. Never
|
|
110
|
+
#: by row id, which carries the group — a family opened By component
|
|
111
|
+
#: closed under Flat — nor by its members, which a filter narrows.
|
|
112
|
+
self.opened: set[tuple[str, bool]] = set()
|
|
113
|
+
#: The test's tracked names, as the last rebuild read them.
|
|
114
|
+
self.tracked: frozenset[str] = frozenset()
|
|
115
|
+
#: The texts two or more of the drawn rows read as.
|
|
116
|
+
self.alike: set[str] = set()
|
|
117
|
+
#: Why "+ new plot" is grey on every row — O2's gate, worded by the
|
|
118
|
+
#: run state — as the last build read it, or None.
|
|
119
|
+
self.withheld: str | None = None
|
|
120
|
+
#: The filter and its toggles are the tab's (`TabState`): a switch
|
|
121
|
+
#: of test builds a new drawer, and it starts from them.
|
|
122
|
+
self.state = page.state
|
|
123
|
+
#: What the last rebuild read, for the clicks on the rows it drew.
|
|
124
|
+
self.texts: dict[str, str] = {}
|
|
125
|
+
#: What the drawn rows were built from (`_signature`): `refresh`
|
|
126
|
+
#: builds again only when it moved.
|
|
127
|
+
self.drawn: object = None
|
|
128
|
+
with panel:
|
|
129
|
+
ui.label("Variables").classes("text-subtitle2")
|
|
130
|
+
state = self.state
|
|
131
|
+
self.box = filter_box(
|
|
132
|
+
state.vars_needle,
|
|
133
|
+
state.vars_regex,
|
|
134
|
+
state.vars_case,
|
|
135
|
+
self._on_filter,
|
|
136
|
+
"filter: substring, or a glob with * ?",
|
|
137
|
+
"var-",
|
|
138
|
+
)
|
|
139
|
+
with ui.row().classes("items-center no-wrap gap-0 w-full"):
|
|
140
|
+
menu_button(
|
|
141
|
+
"sort",
|
|
142
|
+
"Sort",
|
|
143
|
+
(("file", "Result-file order"), ("name", "A→Z")),
|
|
144
|
+
state.vars_sort,
|
|
145
|
+
partial(self._set, "vars_sort"),
|
|
146
|
+
"var-sort",
|
|
147
|
+
)
|
|
148
|
+
menu_button(
|
|
149
|
+
"account_tree",
|
|
150
|
+
"Group",
|
|
151
|
+
(
|
|
152
|
+
("component", "By component"),
|
|
153
|
+
("flat", "Flat"),
|
|
154
|
+
("tracked", "Tracked first"),
|
|
155
|
+
),
|
|
156
|
+
state.vars_group,
|
|
157
|
+
partial(self._set, "vars_group"),
|
|
158
|
+
"var-group-by",
|
|
159
|
+
)
|
|
160
|
+
menu_button(
|
|
161
|
+
"label",
|
|
162
|
+
"Names",
|
|
163
|
+
(
|
|
164
|
+
("expression", "Expression"),
|
|
165
|
+
("file", "Result-file name"),
|
|
166
|
+
("both", "Both"),
|
|
167
|
+
),
|
|
168
|
+
state.vars_names,
|
|
169
|
+
partial(self._set, "vars_names"),
|
|
170
|
+
"var-names",
|
|
171
|
+
)
|
|
172
|
+
# The plot the checks act on, with the ring its card wears; a
|
|
173
|
+
# click brings that card into view.
|
|
174
|
+
with (
|
|
175
|
+
ui.row()
|
|
176
|
+
.classes("dstf-var-target items-center no-wrap gap-2")
|
|
177
|
+
.on("click", self._reveal)
|
|
178
|
+
):
|
|
179
|
+
ui.element("span").classes("dstf-var-ring")
|
|
180
|
+
self.target = ui.label().mark("var-target")
|
|
181
|
+
self.body = ui.column().classes("w-full gap-0")
|
|
182
|
+
self.rebuild()
|
|
183
|
+
|
|
184
|
+
def _on_filter(self, needle: str, regex: bool, case: bool) -> None:
|
|
185
|
+
state = self.state
|
|
186
|
+
state.vars_needle, state.vars_regex, state.vars_case = needle, regex, case
|
|
187
|
+
self.rebuild()
|
|
188
|
+
|
|
189
|
+
def _set(self, key: str, value: str) -> None:
|
|
190
|
+
"""A menu's choice, the tab's (`TabState`)."""
|
|
191
|
+
setattr(self.state, key, value)
|
|
192
|
+
self.rebuild()
|
|
193
|
+
|
|
194
|
+
def _reveal(self) -> None:
|
|
195
|
+
page = self.page
|
|
196
|
+
if 0 <= page.selected < len(page.cards):
|
|
197
|
+
card = page.cards[page.selected].card
|
|
198
|
+
ui.run_javascript(
|
|
199
|
+
f'document.getElementById("{card.html_id}")'
|
|
200
|
+
'?.scrollIntoView({block: "nearest"})'
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# -- the rows ----------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def rebuild(self) -> None:
|
|
206
|
+
"""Every row from the Session as it is now, against the selected
|
|
207
|
+
plot, whether it moved or not: the drawer opening, its filter, a
|
|
208
|
+
family opened."""
|
|
209
|
+
self._draw(self._read())
|
|
210
|
+
|
|
211
|
+
def refresh(self) -> None:
|
|
212
|
+
"""The page moved (`Rail.refresh`: a select, a sync): build again
|
|
213
|
+
only when what the rows are drawn from did. `TestPage.sync` ends
|
|
214
|
+
here, and a run's events rebuilt every row of an open drawer each
|
|
215
|
+
time over a list that had not changed."""
|
|
216
|
+
reads = self._read()
|
|
217
|
+
if self._signature(reads) != self.drawn:
|
|
218
|
+
self._draw(reads)
|
|
219
|
+
|
|
220
|
+
def _read(self) -> _Reads | None:
|
|
221
|
+
"""ONE read of each — the names, the tracked names, the texts, the
|
|
222
|
+
plots — or None on a page whose test is gone: every read raises for
|
|
223
|
+
a model the open project does not have."""
|
|
224
|
+
page = self.page
|
|
225
|
+
if page.gone:
|
|
226
|
+
return None
|
|
227
|
+
session, model_id = page.shell.session, page.model_id
|
|
228
|
+
names = session.variable_names(model_id)
|
|
229
|
+
project = session.project
|
|
230
|
+
test = project.test(model_id) if project is not None else None
|
|
231
|
+
return _Reads(
|
|
232
|
+
names=names,
|
|
233
|
+
tracked=session.tracked(model_id),
|
|
234
|
+
scope=session.variable_texts(model_id),
|
|
235
|
+
plots=session.plots(model_id),
|
|
236
|
+
unit_tests=frozenset(
|
|
237
|
+
n for n in names if test is not None and is_unit_test_variable(test, n)
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _signature(self, reads: _Reads | None) -> object:
|
|
242
|
+
"""Everything a build draws from: the reads, the selected plot and
|
|
243
|
+
the number of plots, the run state and O2's gate, the filter and
|
|
244
|
+
the menus, the families opened, and the reason an empty result
|
|
245
|
+
gives."""
|
|
246
|
+
page = self.page
|
|
247
|
+
head = (
|
|
248
|
+
page.selected,
|
|
249
|
+
len(page.cards),
|
|
250
|
+
page.run_state(),
|
|
251
|
+
page.plots_withheld(),
|
|
252
|
+
page.result() is None,
|
|
253
|
+
self.state.vars_needle,
|
|
254
|
+
self.state.vars_regex,
|
|
255
|
+
self.state.vars_case,
|
|
256
|
+
self.state.vars_group,
|
|
257
|
+
self.state.vars_names,
|
|
258
|
+
self.state.vars_sort,
|
|
259
|
+
frozenset(self.opened),
|
|
260
|
+
)
|
|
261
|
+
if reads is None:
|
|
262
|
+
return head, None
|
|
263
|
+
return head, (
|
|
264
|
+
tuple(reads.names),
|
|
265
|
+
tuple(reads.tracked),
|
|
266
|
+
tuple((n, t.label, t.text) for n, t in reads.scope.items()),
|
|
267
|
+
reads.plots,
|
|
268
|
+
reads.unit_tests,
|
|
269
|
+
"" if reads.names else page.unscored_reason(),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _draw(self, reads: _Reads | None) -> None:
|
|
273
|
+
"""Build the rows, and record what they were built from only once
|
|
274
|
+
they are: a draw that raised had recorded it first, and every later
|
|
275
|
+
refresh then saw nothing moved and left the cleared body empty. A
|
|
276
|
+
bad regex, or one that ran out of time, is a draw that completed —
|
|
277
|
+
it is drawn as its reason."""
|
|
278
|
+
signature = self._signature(reads)
|
|
279
|
+
self.drawn = None
|
|
280
|
+
self._build(reads)
|
|
281
|
+
self.drawn = signature
|
|
282
|
+
|
|
283
|
+
def _build(self, reads: _Reads | None) -> None:
|
|
284
|
+
page = self.page
|
|
285
|
+
self.body.clear()
|
|
286
|
+
if reads is None:
|
|
287
|
+
self.target.set_text("")
|
|
288
|
+
return
|
|
289
|
+
n = len(page.cards)
|
|
290
|
+
# O2's gate: a result that is not a success has no plots — nothing
|
|
291
|
+
# to add context to, and a watch plot made on it would draw nothing.
|
|
292
|
+
# Its run failed (`run_state`), as every line of the page says.
|
|
293
|
+
run = page.run_state()
|
|
294
|
+
self.withheld = RUN_FAILED if page.plots_withheld() else None
|
|
295
|
+
self.target.set_text(
|
|
296
|
+
f"Checkboxes add context to plot {page.selected + 1} of {n}"
|
|
297
|
+
if n
|
|
298
|
+
else RUN_FAILED
|
|
299
|
+
if run == "failed"
|
|
300
|
+
else "No plot yet: + new plot adds one"
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
state = self.state
|
|
304
|
+
keep = matcher(
|
|
305
|
+
state.vars_needle, regex=state.vars_regex, case=state.vars_case
|
|
306
|
+
)
|
|
307
|
+
except regex.error as e:
|
|
308
|
+
self.box.say(f"not a regular expression: {e}")
|
|
309
|
+
return
|
|
310
|
+
self.box.say(None)
|
|
311
|
+
names = reads.names
|
|
312
|
+
project = page.shell.session.project
|
|
313
|
+
with self.body:
|
|
314
|
+
if not names:
|
|
315
|
+
ui.label(
|
|
316
|
+
page.unscored_reason()
|
|
317
|
+
if run != "ok"
|
|
318
|
+
else "the result has no variables"
|
|
319
|
+
).classes("dstf-why").mark("var-empty")
|
|
320
|
+
return
|
|
321
|
+
scope = reads.scope
|
|
322
|
+
self.texts = {name: t.text for name, t in scope.items()}
|
|
323
|
+
self.tracked = frozenset(reads.tracked)
|
|
324
|
+
# Everything but the names and the filter, for the members too.
|
|
325
|
+
groups_of = partial(
|
|
326
|
+
variable_groups,
|
|
327
|
+
tracked=reads.tracked,
|
|
328
|
+
diagnostics=(
|
|
329
|
+
project.config.diagnostic_variables if project is not None else ()
|
|
330
|
+
),
|
|
331
|
+
labels={name: t.label for name, t in scope.items()},
|
|
332
|
+
plots=reads.plots,
|
|
333
|
+
unit_tests=reads.unit_tests,
|
|
334
|
+
selected=page.selected if n else None,
|
|
335
|
+
group=self.state.vars_group,
|
|
336
|
+
order=self.state.vars_sort,
|
|
337
|
+
read=self.state.vars_names,
|
|
338
|
+
)
|
|
339
|
+
# `keep` is only ever asked here: a typed regex that ran out of
|
|
340
|
+
# its budget has drawn nothing yet, and draws nothing
|
|
341
|
+
try:
|
|
342
|
+
found = groups_of(names=names, keep=keep)
|
|
343
|
+
except TimeoutError:
|
|
344
|
+
self.box.say("the pattern took too long; simplify it")
|
|
345
|
+
return
|
|
346
|
+
groups, shown, total = cap(found, ROWS)
|
|
347
|
+
drawn = [
|
|
348
|
+
(group, [(row, self._members(row, groups_of)) for row in group.rows])
|
|
349
|
+
for group in groups
|
|
350
|
+
]
|
|
351
|
+
# The one label rule (`display_label`) over what the drawer
|
|
352
|
+
# draws: a tracked expression `a.Q` beside an untracked variable
|
|
353
|
+
# NAMED `a.Q` is no collision in the test's scope when no plot or
|
|
354
|
+
# criterion holds the second, and two rows read "a.Q".
|
|
355
|
+
reads_as = Counter(
|
|
356
|
+
r.text
|
|
357
|
+
for _, rows in drawn
|
|
358
|
+
for row, members in rows
|
|
359
|
+
for r in (row, *members)
|
|
360
|
+
)
|
|
361
|
+
self.alike = {text for text, n in reads_as.items() if n > 1}
|
|
362
|
+
for group, rows in drawn:
|
|
363
|
+
ui.label(group.title).classes("dstf-group").mark(
|
|
364
|
+
f"var-group:{group.rank}-{_slug(group.title)}"
|
|
365
|
+
)
|
|
366
|
+
for row, members in rows:
|
|
367
|
+
self._row(row)
|
|
368
|
+
for member in members:
|
|
369
|
+
self._row(member, member=True)
|
|
370
|
+
if members and len(row.members) > MEMBERS:
|
|
371
|
+
ui.label(f"… {len(row.members) - MEMBERS} more").classes(
|
|
372
|
+
"dstf-why dstf-var-member"
|
|
373
|
+
)
|
|
374
|
+
if shown < total:
|
|
375
|
+
ui.label(
|
|
376
|
+
f"Showing {shown} of {total:,} rows — narrow the filter"
|
|
377
|
+
).classes("dstf-why").mark("var-cap")
|
|
378
|
+
|
|
379
|
+
def _members(
|
|
380
|
+
self, row: VarRow, groups_of: Callable[..., list[VarGroup]]
|
|
381
|
+
) -> list[VarRow]:
|
|
382
|
+
"""An opened family's members, each drawn as a row of its own under
|
|
383
|
+
it (none for a family not opened). Each is `variable_groups`' row for
|
|
384
|
+
that one name — the same rules, the same state — with an id under its
|
|
385
|
+
family's."""
|
|
386
|
+
if len(row.members) < 2 or self._family(row) not in self.opened:
|
|
387
|
+
return []
|
|
388
|
+
out = []
|
|
389
|
+
for name in row.members[:MEMBERS]:
|
|
390
|
+
(group,) = groups_of(names=[name], keep=lambda _: True)
|
|
391
|
+
(member,) = group.rows
|
|
392
|
+
out.append(replace(member, id=f"{row.id}/{name}"))
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
def _row(self, row: VarRow, *, member: bool = False) -> None:
|
|
396
|
+
page, key = self.page, row.id
|
|
397
|
+
# What is muted beside the row's text: a lone variable's name. A
|
|
398
|
+
# family has none to mute: rows fold by NAME, so its text is its
|
|
399
|
+
# names folded (`file_text`).
|
|
400
|
+
muted = row.name
|
|
401
|
+
said = row.text if muted is None else f"{row.text} ({muted})"
|
|
402
|
+
# The names menu: the expression alone, the result-file name alone,
|
|
403
|
+
# or both — the expression with the name muted beside it. The
|
|
404
|
+
# expression alone still keeps the name beside a label another name
|
|
405
|
+
# of the test carries (`variable_texts`, the one label rule) or
|
|
406
|
+
# another row drawn here reads (`alike`): two rows reading "a.Q"
|
|
407
|
+
# said nothing of which was which. A family has no one name for the
|
|
408
|
+
# scope test: `alike` is what says it reads as another drawn row does.
|
|
409
|
+
names = self.state.vars_names
|
|
410
|
+
show_muted = muted is not None and (
|
|
411
|
+
names == "both"
|
|
412
|
+
or (
|
|
413
|
+
names == "expression"
|
|
414
|
+
and (
|
|
415
|
+
(row.name is not None and self.texts.get(row.name) != row.text)
|
|
416
|
+
or row.text in self.alike
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
# Two lines (2026-09-29): the label alone on the first, after its
|
|
421
|
+
# controls, with the row's whole width — a real library's labels
|
|
422
|
+
# outran the one line they shared with the name, "plot N" and
|
|
423
|
+
# "+ new plot" — and those, muted, on the second. A second line that
|
|
424
|
+
# would hold nothing but the hover-only "+ new plot" collapses, the
|
|
425
|
+
# `+` at line 1's right (K6): a list of untracked names was every
|
|
426
|
+
# other line empty.
|
|
427
|
+
two_lines = show_muted or bool(row.elsewhere)
|
|
428
|
+
with (
|
|
429
|
+
ui.column()
|
|
430
|
+
.classes(
|
|
431
|
+
"dstf-var-row gap-0 w-full" + (" dstf-var-member" if member else "")
|
|
432
|
+
)
|
|
433
|
+
.mark(marker("var-row", key))
|
|
434
|
+
):
|
|
435
|
+
with ui.row().classes("dstf-var-line1 items-center no-wrap gap-1 w-full"):
|
|
436
|
+
# Names go on the props dict, never through a props string: a
|
|
437
|
+
# name can hold a `"`. A check's only text was its icon's
|
|
438
|
+
# ligature, `check_box_outline_blank`.
|
|
439
|
+
if len(row.members) > 1:
|
|
440
|
+
opened = self._family(row) in self.opened
|
|
441
|
+
chevron = (
|
|
442
|
+
ui.button(
|
|
443
|
+
icon="expand_more" if opened else "chevron_right",
|
|
444
|
+
on_click=partial(self._open, self._family(row)),
|
|
445
|
+
)
|
|
446
|
+
.props("flat dense round size=sm")
|
|
447
|
+
.mark(marker("var-open", key))
|
|
448
|
+
)
|
|
449
|
+
chevron.props["aria-label"] = (
|
|
450
|
+
f"{'Close' if opened else 'Open'} {said}"
|
|
451
|
+
)
|
|
452
|
+
chevron.props["aria-expanded"] = "true" if opened else "false"
|
|
453
|
+
checked, icon = _state(row)
|
|
454
|
+
# A disabled Quasar button takes no pointer events: the
|
|
455
|
+
# reason's tooltip is on a span around it.
|
|
456
|
+
with ui.element("span").mark(marker("var-why", key)):
|
|
457
|
+
check = (
|
|
458
|
+
ui.button(icon=icon, on_click=partial(self._check, row))
|
|
459
|
+
.props(
|
|
460
|
+
"flat dense round size=sm "
|
|
461
|
+
f'role="checkbox" aria-checked="{checked}"'
|
|
462
|
+
)
|
|
463
|
+
.mark(marker("var-check", key))
|
|
464
|
+
)
|
|
465
|
+
# A row with a muted name says it too: two rows
|
|
466
|
+
# reading "a.Q" had two checks a screen reader read alike.
|
|
467
|
+
check.props["aria-label"] = said
|
|
468
|
+
if not page.cards:
|
|
469
|
+
check.disable() # no plot to act on: "+ new plot" makes one
|
|
470
|
+
elif row.is_subject:
|
|
471
|
+
# A plot cannot lose its subject: a derived plot's is
|
|
472
|
+
# the tree's, a watch plot's is the plot.
|
|
473
|
+
check.disable()
|
|
474
|
+
ui.tooltip("The plot's subject")
|
|
475
|
+
if len(row.members) > 1:
|
|
476
|
+
with ui.element("span").classes("dstf-var-ramp"):
|
|
477
|
+
for stop in _RAMP:
|
|
478
|
+
ui.element("span").style(f"background: {stop}")
|
|
479
|
+
# A real library's names outrun the panel and are cut in the
|
|
480
|
+
# middle: the text carries all of it, and the name, as its
|
|
481
|
+
# title (on the props dict: a name can hold a `"`).
|
|
482
|
+
shown = row.file_text if names == "file" else row.text
|
|
483
|
+
text = MiddleCut(shown).classes("dstf-var-text dstf-mono text-xs")
|
|
484
|
+
text.props["title"] = said
|
|
485
|
+
if not two_lines:
|
|
486
|
+
ui.space()
|
|
487
|
+
self._new_button(row, key)
|
|
488
|
+
if two_lines:
|
|
489
|
+
with ui.row().classes(
|
|
490
|
+
"dstf-var-line2 items-center no-wrap gap-2 w-full"
|
|
491
|
+
):
|
|
492
|
+
if show_muted and muted is not None:
|
|
493
|
+
ui.label(muted).classes("dstf-varname")
|
|
494
|
+
ui.space()
|
|
495
|
+
# These two never wrap (the stylesheet's `.dstf-var-where`
|
|
496
|
+
# rule keeps each to one line and unshrunk): squeezed by a
|
|
497
|
+
# long name, "+ new plot" took two lines and "plot 2, 3"
|
|
498
|
+
# broke after "plot".
|
|
499
|
+
if row.elsewhere:
|
|
500
|
+
ui.label(
|
|
501
|
+
"plot " + ", ".join(str(i + 1) for i in row.elsewhere)
|
|
502
|
+
).classes("dstf-why dstf-var-where")
|
|
503
|
+
self._new_button(row, key)
|
|
504
|
+
|
|
505
|
+
def _new_button(self, row: VarRow, key: str) -> None:
|
|
506
|
+
""" "+ new plot": a watch plot is of ONE variable, and a variable has
|
|
507
|
+
one plot at most — grey, with why, on a family's row (its members,
|
|
508
|
+
opened, each make one) and on any plot's subject."""
|
|
509
|
+
why = (
|
|
510
|
+
self.withheld
|
|
511
|
+
if self.withheld is not None
|
|
512
|
+
else f"Already the subject of plot {row.subject_of + 1}"
|
|
513
|
+
if row.subject_of is not None
|
|
514
|
+
else "A watch plot shows one variable: open the family"
|
|
515
|
+
if len(row.members) > 1
|
|
516
|
+
else None
|
|
517
|
+
)
|
|
518
|
+
with ui.element("span").mark(marker("var-new-why", key)):
|
|
519
|
+
new = (
|
|
520
|
+
ui.button("+ new plot", on_click=partial(self._new, row))
|
|
521
|
+
.props("flat dense no-caps size=sm")
|
|
522
|
+
.classes("dstf-var-new")
|
|
523
|
+
.mark(marker("var-new", key))
|
|
524
|
+
)
|
|
525
|
+
if why is not None:
|
|
526
|
+
new.disable()
|
|
527
|
+
ui.tooltip(why)
|
|
528
|
+
|
|
529
|
+
# -- the edits -----------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def _family(self, row: VarRow) -> tuple[str, bool]:
|
|
532
|
+
"""What an opened family is known by: its key, and whether it is
|
|
533
|
+
tracked. The key alone is not enough — a tracked family whose
|
|
534
|
+
expressions are ``pipe.T[i]`` keys ``pipe.T[]`` as the result's own
|
|
535
|
+
``pipe.T[i]`` do — and a family is all tracked or all not
|
|
536
|
+
(`variable_rows` folds the two apart)."""
|
|
537
|
+
return row.key, row.members[0] in self.tracked
|
|
538
|
+
|
|
539
|
+
def _open(self, family: tuple[str, bool]) -> None:
|
|
540
|
+
if family in self.opened:
|
|
541
|
+
self.opened.discard(family)
|
|
542
|
+
else:
|
|
543
|
+
self.opened.add(family)
|
|
544
|
+
self.rebuild()
|
|
545
|
+
|
|
546
|
+
def _check(self, row: VarRow) -> None:
|
|
547
|
+
"""The row as CONTEXT on the selected plot: all of its members
|
|
548
|
+
there take them off, else the missing ones are added — never the
|
|
549
|
+
plot's subject.
|
|
550
|
+
|
|
551
|
+
``row`` was drawn before the last change, and the selection may name
|
|
552
|
+
a plot there no longer is: a real double-click raised IndexError on
|
|
553
|
+
its second click once. So the plots are read NOW, a selection
|
|
554
|
+
outside them does nothing, and on or off is what that plot holds
|
|
555
|
+
now — never ``row.on_selected``."""
|
|
556
|
+
page = self.page
|
|
557
|
+
session = page.shell.session
|
|
558
|
+
plots = session.plots(page.model_id)
|
|
559
|
+
if not 0 <= page.selected < len(plots):
|
|
560
|
+
return
|
|
561
|
+
plot = plots[page.selected]
|
|
562
|
+
members = [m for m in row.members if m not in plot.subject]
|
|
563
|
+
if not members:
|
|
564
|
+
return # the plot's subject: its check is grey
|
|
565
|
+
extras: tuple[PlotExtra, ...] = session.layout(page.model_id)
|
|
566
|
+
if all(m in plot.context for m in members):
|
|
567
|
+
for name in members:
|
|
568
|
+
extras = remove_context(extras, plot, name)
|
|
569
|
+
else:
|
|
570
|
+
extras = add_context(extras, plot, members)
|
|
571
|
+
page.stage_layout(extras)
|
|
572
|
+
|
|
573
|
+
def _new(self, row: VarRow) -> None:
|
|
574
|
+
"""A WATCH plot of the row's one variable, after every other plot,
|
|
575
|
+
selected. Read NOW: a variable some plot has as its subject — a
|
|
576
|
+
second click before the page caught up with the first — makes
|
|
577
|
+
none, and neither does a result that is not a success (O2's gate)
|
|
578
|
+
since the row was drawn."""
|
|
579
|
+
page = self.page
|
|
580
|
+
session = page.shell.session
|
|
581
|
+
plots = session.plots(page.model_id)
|
|
582
|
+
if (
|
|
583
|
+
page.plots_withheld()
|
|
584
|
+
or len(row.members) != 1
|
|
585
|
+
or any(row.members[0] in p.subject for p in plots)
|
|
586
|
+
):
|
|
587
|
+
return
|
|
588
|
+
extras = new_watch(session.layout(page.model_id), row.members[0])
|
|
589
|
+
page.stage_layout(extras, select=len(plots))
|